Skip to content

Commit

Permalink
feat: implement on/off event methods in cip30.experimental
Browse files Browse the repository at this point in the history
  • Loading branch information
mirceahasegan committed Oct 3, 2024
1 parent 15b68e6 commit fe0925d
Show file tree
Hide file tree
Showing 10 changed files with 270 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/dapp-connector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@cardano-sdk/core": "workspace:~",
"@cardano-sdk/crypto": "workspace:~",
"@cardano-sdk/util": "workspace:~",
"rxjs": "^7.4.0",
"ts-custom-error": "^3.2.0",
"ts-log": "^2.2.4",
"webextension-polyfill": "^0.8.0"
Expand Down
89 changes: 89 additions & 0 deletions packages/dapp-connector/src/WalletApi/Cip30EventRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Cardano } from '@cardano-sdk/core';
import { Observable, Subscription } from 'rxjs';

export type AccountChangeCb = (addresses: Cardano.BaseAddress[]) => unknown;
export type NetworkChangeCb = (network: Cardano.NetworkId) => unknown;
export enum Cip30EventName {
'accountChange' = 'accountChange',
'networkChange' = 'networkChange'
}
export type Cip30EventMethod = (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) => void;
export type Cip30Event = { eventName: Cip30EventName; data: Cardano.NetworkId | Cardano.BaseAddress[] };
type Cip30NetworkChangeEvent = { eventName: Cip30EventName.networkChange; data: Cardano.NetworkId };
type Cip30AccountChangeEvent = { eventName: Cip30EventName.accountChange; data: Cardano.BaseAddress[] };
type Cip30EventRegistryMap = {
accountChange: AccountChangeCb[];
networkChange: NetworkChangeCb[];
};

const isNetworkChangeEvent = (event: Cip30Event): event is Cip30NetworkChangeEvent =>
event.eventName === Cip30EventName.networkChange;

const isAccountChangeEvent = (event: Cip30Event): event is Cip30AccountChangeEvent =>
event.eventName === Cip30EventName.accountChange;

/**
* This class is responsible for registering and deregistering callbacks for specific events.
* It also handles calling the registered callbacks.
*/
export class Cip30EventRegistry {
#cip30Event$: Observable<Cip30Event>;
#registry: Cip30EventRegistryMap;
#subscription: Subscription;

constructor(cip30Event$: Observable<Cip30Event>) {
this.#cip30Event$ = cip30Event$;
this.#registry = {
accountChange: [],
networkChange: []
};

this.#subscription = this.#cip30Event$.subscribe((event) => {
if (isNetworkChangeEvent(event)) {
const { data } = event;
for (const callback of this.#registry.networkChange) callback(data);
} else if (isAccountChangeEvent(event)) {
const { data } = event;
for (const callback of this.#registry.accountChange) callback(data);
}
});
}

/**
* Register a callback for a specific event name.
*
* @param eventName - The event name to register the callback for.
* @param callback - The callback to be called when the event is triggered.
*/
register(eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) {
if (this.#subscription.closed) return;

if (eventName === Cip30EventName.accountChange) {
this.#registry.accountChange.push(callback as AccountChangeCb);
} else if (eventName === Cip30EventName.networkChange) {
this.#registry.networkChange.push(callback as NetworkChangeCb);
}
}

/**
* Deregister a callback for a specific event name. The callback must be the same reference used on registration.
*
* @param eventName - The event name to deregister the callback from.
* @param callback - The callback to be deregistered.
*/
deregister(eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) {
if (this.#subscription.closed) return;

if (eventName === Cip30EventName.accountChange) {
this.#registry.accountChange = this.#registry.accountChange.filter((cb) => cb !== callback);
} else if (eventName === Cip30EventName.networkChange) {
this.#registry.networkChange = this.#registry.networkChange.filter((cb) => cb !== callback);
}
}

/** Unsubscribe from the event stream. Once called, the registry can no longer be used. */
shutdown() {
if (this.#subscription.closed) return;
this.#subscription.unsubscribe();
}
}
29 changes: 28 additions & 1 deletion packages/dapp-connector/src/WalletApi/Cip30Wallet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { APIErrorCode, ApiError } from '../errors';
import { AccountChangeCb, Cip30EventName, Cip30EventRegistry, NetworkChangeCb } from './Cip30EventRegistry';
import {
Bytes,
Cbor,
Expand All @@ -12,6 +13,7 @@ import {
import { Cardano } from '@cardano-sdk/core';
import { Logger } from 'ts-log';
import { RemoteAuthenticator } from '../AuthenticatorApi';
import { map, merge } from 'rxjs';

export const CipMethodsMapping: Record<number, WalletMethod[]> = {
30: [
Expand Down Expand Up @@ -79,6 +81,7 @@ export class Cip30Wallet {
readonly #api: WalletApi;
readonly #authenticator: RemoteAuthenticator;
readonly #deviations: WalletProperties['cip30ApiDeviations'];
#eventRegistry: Cip30EventRegistry;

constructor(properties: WalletProperties, { api, authenticator, logger }: WalletDependencies) {
this.icon = properties.icon;
Expand All @@ -92,6 +95,12 @@ export class Cip30Wallet {
if (properties.supportedExtensions) {
this.supportedExtensions = properties.supportedExtensions;
}
this.#eventRegistry = new Cip30EventRegistry(
merge(
api.network$.pipe(map((data) => ({ data, eventName: Cip30EventName.networkChange }))),
api.baseAddresses$.pipe(map((data) => ({ data, eventName: Cip30EventName.accountChange })))
)
);
}

#validateExtensions(extensions: WalletApiExtension[] = []): void {
Expand Down Expand Up @@ -164,7 +173,25 @@ export class Cip30Wallet {
const baseApi: Cip30WalletApiWithPossibleExtensions = {
// Add experimental.getCollateral to CIP-30 API
experimental: {
getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params)
getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params),

/**
* Deregister the callback from the event.
*
* @param {EventName} eventName The event to deregister from. Accepted values are 'accountChange' | 'networkChange'
* @param {AccountChangeCb | NetworkChangeCb} callback Must be the same cb reference used on registration.
*/
off: (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) =>
this.#eventRegistry.deregister(eventName, callback),

/**
* Register to events coming from the wallet. Registrations are stored by callback reference.
*
* @param {EventName} eventName The event to register to. Accepted values are 'accountChange' | 'networkChange'
* @param {AccountChangeCb | NetworkChangeCb} callback The callback to be called when the event is triggered.
*/
on: (eventName: Cip30EventName, callback: AccountChangeCb | NetworkChangeCb) =>
this.#eventRegistry.register(eventName, callback)
},
getBalance: () => walletApi.getBalance(),
getChangeAddress: () => walletApi.getChangeAddress(),
Expand Down
8 changes: 7 additions & 1 deletion packages/dapp-connector/src/WalletApi/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Cardano } from '@cardano-sdk/core';
import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
import { HexBlob } from '@cardano-sdk/util';
import { Observable } from 'rxjs';
import { Runtime } from 'webextension-polyfill';

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

export interface Cip30ExperimentalApi {
network$: Observable<Cardano.NetworkId>;
baseAddresses$: Observable<Cardano.BaseAddress[]>;
}

export interface Cip95WalletApi {
getRegisteredPubStakeKeys: () => Promise<Ed25519PublicKeyHex[]>;
getUnregisteredPubStakeKeys: () => Promise<Ed25519PublicKeyHex[]>;
getPubDRepKey: () => Promise<Ed25519PublicKeyHex>;
}

export type WalletApi = Cip30WalletApi & Cip95WalletApi;
export type WalletApi = Cip30WalletApi & Cip30ExperimentalApi & Cip95WalletApi;
export type WalletMethod = keyof WalletApi;

export interface CipExtensionApis {
Expand Down
96 changes: 96 additions & 0 deletions packages/dapp-connector/test/WalletApi/CIp30EventRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Cardano } from '@cardano-sdk/core';
import { Cip30Event, Cip30EventName, Cip30EventRegistry } from '../../src/WalletApi/Cip30EventRegistry';
import { Subject } from 'rxjs';

describe('Cip30EventRegistry', () => {
let cip30Event$: Subject<Cip30Event>;
let registry: Cip30EventRegistry;

beforeEach(() => {
cip30Event$ = new Subject();
registry = new Cip30EventRegistry(cip30Event$);
});

afterEach(() => {
registry.shutdown();
});

it('should register and trigger networkChange callback', () => {
const callback = jest.fn();
registry.register(Cip30EventName.networkChange, callback);

const networkId: Cardano.NetworkId = 1;
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });

expect(callback).toHaveBeenCalledWith(networkId);
});

it('should register and trigger accountChange callback', () => {
const callback = jest.fn();
registry.register(Cip30EventName.accountChange, callback);

const addresses: Cardano.BaseAddress[] = [{} as unknown as Cardano.BaseAddress];
cip30Event$.next({ data: addresses, eventName: Cip30EventName.accountChange });

expect(callback).toHaveBeenCalledWith(addresses);
});

it('should deregister networkChange callback', () => {
const callback = jest.fn();
registry.register(Cip30EventName.networkChange, callback);
registry.deregister(Cip30EventName.networkChange, callback);

const networkId: Cardano.NetworkId = 1;
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });

expect(callback).not.toHaveBeenCalled();
});

it('should deregister accountChange callback', () => {
const callback = jest.fn();
registry.register(Cip30EventName.accountChange, callback);
registry.deregister(Cip30EventName.accountChange, callback);

const addresses: Cardano.BaseAddress[] = [{} as unknown as Cardano.BaseAddress];
cip30Event$.next({ data: addresses, eventName: Cip30EventName.accountChange });

expect(callback).not.toHaveBeenCalled();
});

it('should handle multiple callbacks for the same event', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
registry.register(Cip30EventName.networkChange, callback1);
registry.register(Cip30EventName.networkChange, callback2);

const networkId: Cardano.NetworkId = 1;
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });

expect(callback1).toHaveBeenCalledWith(networkId);
expect(callback2).toHaveBeenCalledWith(networkId);
});

it('should not trigger callbacks after shutdown', () => {
const callback = jest.fn();
registry.register(Cip30EventName.networkChange, callback);

registry.shutdown();

const networkId: Cardano.NetworkId = 1;
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });

expect(callback).not.toHaveBeenCalled();
expect(cip30Event$.observed).toBeFalsy();
});

it('should not register callbacks after shutdown', () => {
const callback = jest.fn();
registry.shutdown();
registry.register(Cip30EventName.networkChange, callback);

const networkId: Cardano.NetworkId = 1;
cip30Event$.next({ data: networkId, eventName: Cip30EventName.networkChange });

expect(callback).not.toHaveBeenCalled();
});
});
4 changes: 4 additions & 0 deletions packages/dapp-connector/test/testWallet.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { BehaviorSubject, NEVER } from 'rxjs';
import { Cardano, Serialization } from '@cardano-sdk/core';
import { Cip30DataSignature, WalletApi, WalletProperties } from '../src/WalletApi';
import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
import { RemoteAuthenticator } from '../src';

export const api = <WalletApi>{
baseAddresses$: NEVER,
getBalance: async () => '100',
getChangeAddress: async () => 'change-address',
getCollateral: async () => null,
Expand Down Expand Up @@ -32,6 +34,8 @@ export const api = <WalletApi>{
}
]).toCbor()
],
network$: new BehaviorSubject(Cardano.NetworkId.Mainnet),
networkId$: NEVER,
signData: async (_addr, _payload) => ({} as Cip30DataSignature),
signTx: async (_tx) => 'signedTransaction',
submitTx: async (_tx) => 'transactionId'
Expand Down
33 changes: 31 additions & 2 deletions packages/wallet/src/cip30.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Bytes,
Cbor,
Cip30DataSignature,
Cip30ExperimentalApi,
Cip95WalletApi,
DataSignError,
DataSignErrorCode,
Expand All @@ -18,11 +19,22 @@ import {
WithSenderContext
} from '@cardano-sdk/dapp-connector';
import { Cardano, Serialization, coalesceValueQuantities } from '@cardano-sdk/core';
import { HexBlob, ManagedFreeableScope } from '@cardano-sdk/util';
import { HexBlob, ManagedFreeableScope, isNotNil } from '@cardano-sdk/util';
import { InputSelectionError, InputSelectionFailure } from '@cardano-sdk/input-selection';
import { Logger } from 'ts-log';
import { MessageSender } from '@cardano-sdk/key-management';
import { Observable, firstValueFrom, from, map, mergeMap, race, throwError } from 'rxjs';
import {
Observable,
distinctUntilChanged,
firstValueFrom,
from,
map,
mergeMap,
race,
switchMap,
take,
throwError
} from 'rxjs';
import { ObservableWallet } from './types';
import { requiresForeignSignatures } from './services';
import uniq from 'lodash/uniq.js';
Expand Down Expand Up @@ -555,6 +567,22 @@ const baseCip30WalletApi = (
}
});

const cip30ExperimentalWalletApi = (wallet$: Observable<ObservableWallet>): Cip30ExperimentalApi => ({
baseAddresses$: wallet$.pipe(
/**
* Using take(1) to emit baseAddresses only when the wallet changes,
* which is equivalent to account change, instead of every time the addresses change.
*/
switchMap((wallet) => wallet.addresses$.pipe(take(1))),
map((addresses) => addresses.map(({ address }) => Cardano.Address.fromBech32(address).asBase()).filter(isNotNil))
),
network$: wallet$.pipe(
switchMap((wallet) => wallet.genesisParameters$),
map((params) => params.networkId),
distinctUntilChanged()
)
});

const getPubStakeKeys = async (
wallet$: Observable<ObservableWallet>,
filter: Cardano.StakeCredentialStatus.Registered | Cardano.StakeCredentialStatus.Unregistered
Expand Down Expand Up @@ -621,5 +649,6 @@ export const createWalletApi = (
{ logger }: Cip30WalletDependencies
): WithSenderContext<WalletApi> => ({
...baseCip30WalletApi(wallet$, confirmationCallback, { logger }),
...cip30ExperimentalWalletApi(wallet$),
...extendedCip95WalletApi(wallet$, { logger })
});
10 changes: 10 additions & 0 deletions packages/wallet/test/integration/cip30mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,16 @@ describe('cip30', () => {
const extensions = await api.getExtensions(context);
expect(extensions).toEqual([{ cip: 95 }]);
});

test('api.baseAddresses', async () => {
const baseAddresses = await firstValueFrom(api.baseAddresses$);
expect(baseAddresses.length).toBe(2);
});

test('api.network', async () => {
const network = await firstValueFrom(api.network$);
expect(network).toEqual(Cardano.NetworkId.Testnet);
});
});

describe('confirmation callbacks', () => {
Expand Down
Loading

0 comments on commit fe0925d

Please sign in to comment.