Skip to content

Commit fe0925d

Browse files
committed
feat: implement on/off event methods in cip30.experimental
1 parent 15b68e6 commit fe0925d

File tree

10 files changed

+270
-5
lines changed

10 files changed

+270
-5
lines changed

packages/dapp-connector/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@cardano-sdk/core": "workspace:~",
5454
"@cardano-sdk/crypto": "workspace:~",
5555
"@cardano-sdk/util": "workspace:~",
56+
"rxjs": "^7.4.0",
5657
"ts-custom-error": "^3.2.0",
5758
"ts-log": "^2.2.4",
5859
"webextension-polyfill": "^0.8.0"
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Cardano } from '@cardano-sdk/core';
2+
import { Observable, Subscription } from 'rxjs';
3+
4+
export type AccountChangeCb = (addresses: Cardano.BaseAddress[]) => unknown;
5+
export type NetworkChangeCb = (network: Cardano.NetworkId) => unknown;
6+
export enum Cip30EventName {
7+
'accountChange' = 'accountChange',
8+
'networkChange' = 'networkChange'
9+
}
10+
export type Cip30EventMethod = (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) => void;
11+
export type Cip30Event = { eventName: Cip30EventName; data: Cardano.NetworkId | Cardano.BaseAddress[] };
12+
type Cip30NetworkChangeEvent = { eventName: Cip30EventName.networkChange; data: Cardano.NetworkId };
13+
type Cip30AccountChangeEvent = { eventName: Cip30EventName.accountChange; data: Cardano.BaseAddress[] };
14+
type Cip30EventRegistryMap = {
15+
accountChange: AccountChangeCb[];
16+
networkChange: NetworkChangeCb[];
17+
};
18+
19+
const isNetworkChangeEvent = (event: Cip30Event): event is Cip30NetworkChangeEvent =>
20+
event.eventName === Cip30EventName.networkChange;
21+
22+
const isAccountChangeEvent = (event: Cip30Event): event is Cip30AccountChangeEvent =>
23+
event.eventName === Cip30EventName.accountChange;
24+
25+
/**
26+
* This class is responsible for registering and deregistering callbacks for specific events.
27+
* It also handles calling the registered callbacks.
28+
*/
29+
export class Cip30EventRegistry {
30+
#cip30Event$: Observable<Cip30Event>;
31+
#registry: Cip30EventRegistryMap;
32+
#subscription: Subscription;
33+
34+
constructor(cip30Event$: Observable<Cip30Event>) {
35+
this.#cip30Event$ = cip30Event$;
36+
this.#registry = {
37+
accountChange: [],
38+
networkChange: []
39+
};
40+
41+
this.#subscription = this.#cip30Event$.subscribe((event) => {
42+
if (isNetworkChangeEvent(event)) {
43+
const { data } = event;
44+
for (const callback of this.#registry.networkChange) callback(data);
45+
} else if (isAccountChangeEvent(event)) {
46+
const { data } = event;
47+
for (const callback of this.#registry.accountChange) callback(data);
48+
}
49+
});
50+
}
51+
52+
/**
53+
* Register a callback for a specific event name.
54+
*
55+
* @param eventName - The event name to register the callback for.
56+
* @param callback - The callback to be called when the event is triggered.
57+
*/
58+
register(eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) {
59+
if (this.#subscription.closed) return;
60+
61+
if (eventName === Cip30EventName.accountChange) {
62+
this.#registry.accountChange.push(callback as AccountChangeCb);
63+
} else if (eventName === Cip30EventName.networkChange) {
64+
this.#registry.networkChange.push(callback as NetworkChangeCb);
65+
}
66+
}
67+
68+
/**
69+
* Deregister a callback for a specific event name. The callback must be the same reference used on registration.
70+
*
71+
* @param eventName - The event name to deregister the callback from.
72+
* @param callback - The callback to be deregistered.
73+
*/
74+
deregister(eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) {
75+
if (this.#subscription.closed) return;
76+
77+
if (eventName === Cip30EventName.accountChange) {
78+
this.#registry.accountChange = this.#registry.accountChange.filter((cb) => cb !== callback);
79+
} else if (eventName === Cip30EventName.networkChange) {
80+
this.#registry.networkChange = this.#registry.networkChange.filter((cb) => cb !== callback);
81+
}
82+
}
83+
84+
/** Unsubscribe from the event stream. Once called, the registry can no longer be used. */
85+
shutdown() {
86+
if (this.#subscription.closed) return;
87+
this.#subscription.unsubscribe();
88+
}
89+
}

packages/dapp-connector/src/WalletApi/Cip30Wallet.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { APIErrorCode, ApiError } from '../errors';
2+
import { AccountChangeCb, Cip30EventName, Cip30EventRegistry, NetworkChangeCb } from './Cip30EventRegistry';
23
import {
34
Bytes,
45
Cbor,
@@ -12,6 +13,7 @@ import {
1213
import { Cardano } from '@cardano-sdk/core';
1314
import { Logger } from 'ts-log';
1415
import { RemoteAuthenticator } from '../AuthenticatorApi';
16+
import { map, merge } from 'rxjs';
1517

1618
export const CipMethodsMapping: Record<number, WalletMethod[]> = {
1719
30: [
@@ -79,6 +81,7 @@ export class Cip30Wallet {
7981
readonly #api: WalletApi;
8082
readonly #authenticator: RemoteAuthenticator;
8183
readonly #deviations: WalletProperties['cip30ApiDeviations'];
84+
#eventRegistry: Cip30EventRegistry;
8285

8386
constructor(properties: WalletProperties, { api, authenticator, logger }: WalletDependencies) {
8487
this.icon = properties.icon;
@@ -92,6 +95,12 @@ export class Cip30Wallet {
9295
if (properties.supportedExtensions) {
9396
this.supportedExtensions = properties.supportedExtensions;
9497
}
98+
this.#eventRegistry = new Cip30EventRegistry(
99+
merge(
100+
api.network$.pipe(map((data) => ({ data, eventName: Cip30EventName.networkChange }))),
101+
api.baseAddresses$.pipe(map((data) => ({ data, eventName: Cip30EventName.accountChange })))
102+
)
103+
);
95104
}
96105

97106
#validateExtensions(extensions: WalletApiExtension[] = []): void {
@@ -164,7 +173,25 @@ export class Cip30Wallet {
164173
const baseApi: Cip30WalletApiWithPossibleExtensions = {
165174
// Add experimental.getCollateral to CIP-30 API
166175
experimental: {
167-
getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params)
176+
getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params),
177+
178+
/**
179+
* Deregister the callback from the event.
180+
*
181+
* @param {EventName} eventName The event to deregister from. Accepted values are 'accountChange' | 'networkChange'
182+
* @param {AccountChangeCb | NetworkChangeCb} callback Must be the same cb reference used on registration.
183+
*/
184+
off: (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) =>
185+
this.#eventRegistry.deregister(eventName, callback),
186+
187+
/**
188+
* Register to events coming from the wallet. Registrations are stored by callback reference.
189+
*
190+
* @param {EventName} eventName The event to register to. Accepted values are 'accountChange' | 'networkChange'
191+
* @param {AccountChangeCb | NetworkChangeCb} callback The callback to be called when the event is triggered.
192+
*/
193+
on: (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) =>
194+
this.#eventRegistry.register(eventName, callback)
168195
},
169196
getBalance: () => walletApi.getBalance(),
170197
getChangeAddress: () => walletApi.getChangeAddress(),

packages/dapp-connector/src/WalletApi/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Cardano } from '@cardano-sdk/core';
22
import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
33
import { HexBlob } from '@cardano-sdk/util';
4+
import { Observable } from 'rxjs';
45
import { Runtime } from 'webextension-polyfill';
56

67
/** A hex-encoded string of the corresponding bytes. */
@@ -199,13 +200,18 @@ export interface Cip30WalletApi {
199200
experimental?: any;
200201
}
201202

203+
export interface Cip30ExperimentalApi {
204+
network$: Observable<Cardano.NetworkId>;
205+
baseAddresses$: Observable<Cardano.BaseAddress[]>;
206+
}
207+
202208
export interface Cip95WalletApi {
203209
getRegisteredPubStakeKeys: () => Promise<Ed25519PublicKeyHex[]>;
204210
getUnregisteredPubStakeKeys: () => Promise<Ed25519PublicKeyHex[]>;
205211
getPubDRepKey: () => Promise<Ed25519PublicKeyHex>;
206212
}
207213

208-
export type WalletApi = Cip30WalletApi & Cip95WalletApi;
214+
export type WalletApi = Cip30WalletApi & Cip30ExperimentalApi & Cip95WalletApi;
209215
export type WalletMethod = keyof WalletApi;
210216

211217
export interface CipExtensionApis {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Cardano } from '@cardano-sdk/core';
2+
import { Cip30Event, Cip30EventName, Cip30EventRegistry } from '../../src/WalletApi/Cip30EventRegistry';
3+
import { Subject } from 'rxjs';
4+
5+
describe('Cip30EventRegistry', () => {
6+
let cip30Event$: Subject<Cip30Event>;
7+
let registry: Cip30EventRegistry;
8+
9+
beforeEach(() => {
10+
cip30Event$ = new Subject();
11+
registry = new Cip30EventRegistry(cip30Event$);
12+
});
13+
14+
afterEach(() => {
15+
registry.shutdown();
16+
});
17+
18+
it('should register and trigger networkChange callback', () => {
19+
const callback = jest.fn();
20+
registry.register(Cip30EventName.networkChange, callback);
21+
22+
const networkId: Cardano.NetworkId = 1;
23+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
24+
25+
expect(callback).toHaveBeenCalledWith(networkId);
26+
});
27+
28+
it('should register and trigger accountChange callback', () => {
29+
const callback = jest.fn();
30+
registry.register(Cip30EventName.accountChange, callback);
31+
32+
const addresses: Cardano.BaseAddress[] = [{} as unknown as Cardano.BaseAddress];
33+
cip30Event$.next({ data: addresses, eventName: Cip30EventName.accountChange });
34+
35+
expect(callback).toHaveBeenCalledWith(addresses);
36+
});
37+
38+
it('should deregister networkChange callback', () => {
39+
const callback = jest.fn();
40+
registry.register(Cip30EventName.networkChange, callback);
41+
registry.deregister(Cip30EventName.networkChange, callback);
42+
43+
const networkId: Cardano.NetworkId = 1;
44+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
45+
46+
expect(callback).not.toHaveBeenCalled();
47+
});
48+
49+
it('should deregister accountChange callback', () => {
50+
const callback = jest.fn();
51+
registry.register(Cip30EventName.accountChange, callback);
52+
registry.deregister(Cip30EventName.accountChange, callback);
53+
54+
const addresses: Cardano.BaseAddress[] = [{} as unknown as Cardano.BaseAddress];
55+
cip30Event$.next({ data: addresses, eventName: Cip30EventName.accountChange });
56+
57+
expect(callback).not.toHaveBeenCalled();
58+
});
59+
60+
it('should handle multiple callbacks for the same event', () => {
61+
const callback1 = jest.fn();
62+
const callback2 = jest.fn();
63+
registry.register(Cip30EventName.networkChange, callback1);
64+
registry.register(Cip30EventName.networkChange, callback2);
65+
66+
const networkId: Cardano.NetworkId = 1;
67+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
68+
69+
expect(callback1).toHaveBeenCalledWith(networkId);
70+
expect(callback2).toHaveBeenCalledWith(networkId);
71+
});
72+
73+
it('should not trigger callbacks after shutdown', () => {
74+
const callback = jest.fn();
75+
registry.register(Cip30EventName.networkChange, callback);
76+
77+
registry.shutdown();
78+
79+
const networkId: Cardano.NetworkId = 1;
80+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
81+
82+
expect(callback).not.toHaveBeenCalled();
83+
expect(cip30Event$.observed).toBeFalsy();
84+
});
85+
86+
it('should not register callbacks after shutdown', () => {
87+
const callback = jest.fn();
88+
registry.shutdown();
89+
registry.register(Cip30EventName.networkChange, callback);
90+
91+
const networkId: Cardano.NetworkId = 1;
92+
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });
93+
94+
expect(callback).not.toHaveBeenCalled();
95+
});
96+
});

packages/dapp-connector/test/testWallet.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { BehaviorSubject, NEVER } from 'rxjs';
12
import { Cardano, Serialization } from '@cardano-sdk/core';
23
import { Cip30DataSignature, WalletApi, WalletProperties } from '../src/WalletApi';
34
import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
45
import { RemoteAuthenticator } from '../src';
56

67
export const api = <WalletApi>{
8+
baseAddresses$: NEVER,
79
getBalance: async () => '100',
810
getChangeAddress: async () => 'change-address',
911
getCollateral: async () => null,
@@ -32,6 +34,8 @@ export const api = <WalletApi>{
3234
}
3335
]).toCbor()
3436
],
37+
network$: new BehaviorSubject(Cardano.NetworkId.Mainnet),
38+
networkId$: NEVER,
3539
signData: async (_addr, _payload) => ({} as Cip30DataSignature),
3640
signTx: async (_tx) => 'signedTransaction',
3741
submitTx: async (_tx) => 'transactionId'

packages/wallet/src/cip30.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Bytes,
55
Cbor,
66
Cip30DataSignature,
7+
Cip30ExperimentalApi,
78
Cip95WalletApi,
89
DataSignError,
910
DataSignErrorCode,
@@ -18,11 +19,22 @@ import {
1819
WithSenderContext
1920
} from '@cardano-sdk/dapp-connector';
2021
import { Cardano, Serialization, coalesceValueQuantities } from '@cardano-sdk/core';
21-
import { HexBlob, ManagedFreeableScope } from '@cardano-sdk/util';
22+
import { HexBlob, ManagedFreeableScope, isNotNil } from '@cardano-sdk/util';
2223
import { InputSelectionError, InputSelectionFailure } from '@cardano-sdk/input-selection';
2324
import { Logger } from 'ts-log';
2425
import { MessageSender } from '@cardano-sdk/key-management';
25-
import { Observable, firstValueFrom, from, map, mergeMap, race, throwError } from 'rxjs';
26+
import {
27+
Observable,
28+
distinctUntilChanged,
29+
firstValueFrom,
30+
from,
31+
map,
32+
mergeMap,
33+
race,
34+
switchMap,
35+
take,
36+
throwError
37+
} from 'rxjs';
2638
import { ObservableWallet } from './types';
2739
import { requiresForeignSignatures } from './services';
2840
import uniq from 'lodash/uniq.js';
@@ -555,6 +567,22 @@ const baseCip30WalletApi = (
555567
}
556568
});
557569

570+
const cip30ExperimentalWalletApi = (wallet$: Observable<ObservableWallet>): Cip30ExperimentalApi => ({
571+
baseAddresses$: wallet$.pipe(
572+
/**
573+
* Using take(1) to emit baseAddresses only when the wallet changes,
574+
* which is equivalent to account change, instead of every time the addresses change.
575+
*/
576+
switchMap((wallet) => wallet.addresses$.pipe(take(1))),
577+
map((addresses) => addresses.map(({ address }) => Cardano.Address.fromBech32(address).asBase()).filter(isNotNil))
578+
),
579+
network$: wallet$.pipe(
580+
switchMap((wallet) => wallet.genesisParameters$),
581+
map((params) => params.networkId),
582+
distinctUntilChanged()
583+
)
584+
});
585+
558586
const getPubStakeKeys = async (
559587
wallet$: Observable<ObservableWallet>,
560588
filter: Cardano.StakeCredentialStatus.Registered | Cardano.StakeCredentialStatus.Unregistered
@@ -621,5 +649,6 @@ export const createWalletApi = (
621649
{ logger }: Cip30WalletDependencies
622650
): WithSenderContext<WalletApi> => ({
623651
...baseCip30WalletApi(wallet$, confirmationCallback, { logger }),
652+
...cip30ExperimentalWalletApi(wallet$),
624653
...extendedCip95WalletApi(wallet$, { logger })
625654
});

packages/wallet/test/integration/cip30mapping.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,16 @@ describe('cip30', () => {
757757
const extensions = await api.getExtensions(context);
758758
expect(extensions).toEqual([{ cip: 95 }]);
759759
});
760+
761+
test('api.baseAddresses', async () => {
762+
const baseAddresses = await firstValueFrom(api.baseAddresses$);
763+
expect(baseAddresses.length).toBe(2);
764+
});
765+
766+
test('api.network', async () => {
767+
const network = await firstValueFrom(api.network$);
768+
expect(network).toEqual(Cardano.NetworkId.Testnet);
769+
});
760770
});
761771

762772
describe('confirmation callbacks', () => {

0 commit comments

Comments
 (0)