diff --git a/packages/dapp-connector/src/WalletApi/Cip30Wallet.ts b/packages/dapp-connector/src/WalletApi/Cip30Wallet.ts index 8832780f98e..13f078b04e4 100644 --- a/packages/dapp-connector/src/WalletApi/Cip30Wallet.ts +++ b/packages/dapp-connector/src/WalletApi/Cip30Wallet.ts @@ -32,54 +32,6 @@ export const CipMethodsMapping: Record = { }; export const WalletApiMethodNames: WalletMethod[] = Object.values(CipMethodsMapping).flat(); -/** - * Wrap the proxy API object with a regular javascript object to avoid interop issues with some dApps. - * - * Only return the allowed API methods. - */ -const wrapAndEnableApi = ( - walletApi: WalletApi, - enabledExtensions?: WalletApiExtension[] -): Cip30WalletApiWithPossibleExtensions => { - const baseApi: Cip30WalletApiWithPossibleExtensions = { - // Add experimental.getCollateral to CIP-30 API - experimental: { - getCollateral: (params?: { amount?: Cbor }) => walletApi.getCollateral(params) - }, - getBalance: () => walletApi.getBalance(), - getChangeAddress: () => walletApi.getChangeAddress(), - getCollateral: (params?: { amount?: Cbor }) => walletApi.getCollateral(params), - getExtensions: () => Promise.resolve(enabledExtensions || []), - getNetworkId: () => walletApi.getNetworkId(), - getRewardAddresses: () => walletApi.getRewardAddresses(), - getUnusedAddresses: () => walletApi.getUnusedAddresses(), - getUsedAddresses: (paginate?: Paginate) => walletApi.getUsedAddresses(paginate), - getUtxos: (amount?: Cbor, paginate?: Paginate) => walletApi.getUtxos(amount, paginate), - signData: (addr: Cardano.PaymentAddress | Bytes, payload: Bytes) => walletApi.signData(addr, payload), - signTx: (tx: Cbor, partialSign?: Boolean) => walletApi.signTx(tx, partialSign), - submitTx: (tx: Cbor) => walletApi.submitTx(tx) - }; - - const additionalCipApis: CipExtensionApis = { - cip95: { - getPubDRepKey: () => walletApi.getPubDRepKey(), - getRegisteredPubStakeKeys: () => walletApi.getRegisteredPubStakeKeys(), - getUnregisteredPubStakeKeys: () => walletApi.getUnregisteredPubStakeKeys() - } - }; - - if (enabledExtensions) { - for (const extension of enabledExtensions) { - const cipName = `cip${extension.cip}` as keyof CipExtensionApis; - if (additionalCipApis[cipName]) { - baseApi[cipName] = additionalCipApis[cipName]; - } - } - } - - return baseApi; -}; - /** CIP30 API version */ export type ApiVersion = string; @@ -93,7 +45,19 @@ export type WalletName = string; */ export type WalletIcon = string; -export type WalletProperties = { icon: WalletIcon; walletName: WalletName }; +export type WalletProperties = { + icon: WalletIcon; + walletName: WalletName; + supportedExtensions?: WalletApiExtension[]; + /** Deviations from the CIP30 spec */ + cip30ApiDeviations?: { + /** + * Instead of throwing an error when no collateral is found, return empty array. + * This is the way the Nami wallet works and some DApps rely on it (i.e. https://app.indigoprotocol.io/) + */ + getCollateralEmptyArray?: boolean; + }; +}; export type WalletDependencies = { logger: Logger; @@ -108,11 +72,13 @@ export class Cip30Wallet { readonly apiVersion: ApiVersion = '0.1.0'; readonly name: WalletName; readonly icon: WalletIcon; + /** Support the full api by default */ readonly supportedExtensions: WalletApiExtension[] = [{ cip: 95 }]; readonly #logger: Logger; readonly #api: WalletApi; readonly #authenticator: RemoteAuthenticator; + readonly #deviations: WalletProperties['cip30ApiDeviations']; constructor(properties: WalletProperties, { api, authenticator, logger }: WalletDependencies) { this.icon = properties.icon; @@ -120,8 +86,12 @@ export class Cip30Wallet { this.#api = api; this.#logger = logger; this.#authenticator = authenticator; + this.#deviations = properties.cip30ApiDeviations; this.enable = this.enable.bind(this); this.isEnabled = this.isEnabled.bind(this); + if (properties.supportedExtensions) { + this.supportedExtensions = properties.supportedExtensions; + } } #validateExtensions(extensions: WalletApiExtension[] = []): void { @@ -173,9 +143,60 @@ export class Cip30Wallet { const extensions = options?.extensions?.filter(({ cip: requestedCip }) => this.supportedExtensions.some(({ cip: supportedCip }) => supportedCip === requestedCip) ); - return wrapAndEnableApi(this.#api, extensions); + return this.#wrapAndEnableApi(extensions); } this.#logger.debug(`${location.origin} not authorized to access wallet api`); throw new ApiError(APIErrorCode.Refused, 'wallet not authorized.'); } + + async #wrapGetCollateral(params?: { amount?: Cbor }) { + const collateral = await this.#api.getCollateral(params); + return this.#deviations?.getCollateralEmptyArray ? collateral ?? [] : collateral; + } + + /** + * Wrap the proxy API object with a regular javascript object to avoid interop issues with some dApps. + * + * Only return the allowed API methods. + */ + #wrapAndEnableApi(enabledExtensions?: WalletApiExtension[]): Cip30WalletApiWithPossibleExtensions { + const walletApi = this.#api; + const baseApi: Cip30WalletApiWithPossibleExtensions = { + // Add experimental.getCollateral to CIP-30 API + experimental: { + getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params) + }, + getBalance: () => walletApi.getBalance(), + getChangeAddress: () => walletApi.getChangeAddress(), + getCollateral: (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params), + getExtensions: () => Promise.resolve(enabledExtensions || []), + getNetworkId: () => walletApi.getNetworkId(), + getRewardAddresses: () => walletApi.getRewardAddresses(), + getUnusedAddresses: () => walletApi.getUnusedAddresses(), + getUsedAddresses: (paginate?: Paginate) => walletApi.getUsedAddresses(paginate), + getUtxos: (amount?: Cbor, paginate?: Paginate) => walletApi.getUtxos(amount, paginate), + signData: (addr: Cardano.PaymentAddress | Bytes, payload: Bytes) => walletApi.signData(addr, payload), + signTx: (tx: Cbor, partialSign?: Boolean) => walletApi.signTx(tx, partialSign), + submitTx: (tx: Cbor) => walletApi.submitTx(tx) + }; + + const additionalCipApis: CipExtensionApis = { + cip95: { + getPubDRepKey: () => walletApi.getPubDRepKey(), + getRegisteredPubStakeKeys: () => walletApi.getRegisteredPubStakeKeys(), + getUnregisteredPubStakeKeys: () => walletApi.getUnregisteredPubStakeKeys() + } + }; + + if (enabledExtensions) { + for (const extension of enabledExtensions) { + const cipName = `cip${extension.cip}` as keyof CipExtensionApis; + if (additionalCipApis[cipName]) { + baseApi[cipName] = additionalCipApis[cipName]; + } + } + } + + return baseApi; + } } diff --git a/packages/dapp-connector/src/injectGlobal.ts b/packages/dapp-connector/src/injectGlobal.ts index 0a84a7aff18..42a5d42f692 100644 --- a/packages/dapp-connector/src/injectGlobal.ts +++ b/packages/dapp-connector/src/injectGlobal.ts @@ -3,7 +3,13 @@ import { Logger } from 'ts-log'; export type WindowMaybeWithCardano = Window & { cardano?: { [k: string]: Cip30Wallet } }; -export const injectGlobal = (window: WindowMaybeWithCardano, wallet: Cip30Wallet, logger: Logger): void => { +export const injectGlobal = ( + window: WindowMaybeWithCardano, + wallet: Cip30Wallet, + logger: Logger, + injectKey?: string +): void => { + injectKey = injectKey ?? wallet.name; if (!window.cardano) { logger.debug( { @@ -22,7 +28,7 @@ export const injectGlobal = (window: WindowMaybeWithCardano, wallet: Cip30Wallet 'Cardano global scope exists' ); } - window.cardano[wallet.name] = window.cardano[wallet.name] || wallet; + window.cardano[injectKey] = window.cardano[injectKey] || wallet; logger.debug( { module: 'injectWindow', diff --git a/packages/dapp-connector/test/WalletApi/Cip30Wallet.test.ts b/packages/dapp-connector/test/WalletApi/Cip30Wallet.test.ts index f418454bae8..0369e92bb0d 100644 --- a/packages/dapp-connector/test/WalletApi/Cip30Wallet.test.ts +++ b/packages/dapp-connector/test/WalletApi/Cip30Wallet.test.ts @@ -17,6 +17,8 @@ describe('Wallet', () => { let authenticator: RemoteAuthenticator; let wallet: Cip30Wallet; + let walletNoExtensions: Cip30Wallet; + let walletWithApiDeviations: Cip30Wallet; beforeEach(async () => { await browser.storage.local.clear(); @@ -26,6 +28,24 @@ describe('Wallet', () => { authenticator, logger }); + + walletNoExtensions = new Cip30Wallet( + { ...testWallet.properties, supportedExtensions: [] }, + { + api: testWallet.api, + authenticator, + logger + } + ); + + walletWithApiDeviations = new Cip30Wallet( + { ...testWallet.properties, cip30ApiDeviations: { getCollateralEmptyArray: true } }, + { + api: testWallet.api, + authenticator, + logger + } + ); }); test('constructed state', async () => { @@ -41,6 +61,11 @@ describe('Wallet', () => { expect(typeof wallet.enable).toBe('function'); }); + test('constructed state without extensions', async () => { + expect(walletNoExtensions.name).toBe(testWallet.properties.walletName); + expect(walletNoExtensions.supportedExtensions).toEqual([]); + }); + it('should return initial api as plain javascript object', () => { // Verbose to enable easy detection of which are missing expect(wallet.hasOwnProperty('apiVersion')).toBe(true); @@ -73,6 +98,13 @@ describe('Wallet', () => { expect(await api.getExtensions()).toEqual([{ cip: 95 }]); }); + test('no extensions wallet cannot enable cip95 extension', async () => { + const api = await walletNoExtensions.enable({ extensions: [{ cip: 95 }] }); + expect(await walletNoExtensions.isEnabled()).toBe(true); + expect(await api.getExtensions()).toEqual([]); + expect(api.cip95).toBeUndefined(); + }); + test('change extensions after enabling once', async () => { const cip30api = await wallet.enable(); const cip30methods = new Set(Object.keys(cip30api)); @@ -158,9 +190,11 @@ describe('Wallet', () => { describe('api', () => { let api: Cip30WalletApiWithPossibleExtensions; + let apiWithDeviations: Cip30WalletApiWithPossibleExtensions; beforeAll(async () => { api = await wallet.enable(); + apiWithDeviations = await walletWithApiDeviations.enable(); }); test('getNetworkId', async () => { @@ -262,5 +296,15 @@ describe('Wallet', () => { const extensions = await api.getExtensions(); expect(extensions).toEqual([]); }); + + test('getCollateral', async () => { + expect(api.getCollateral).toBeDefined(); + expect(apiWithDeviations.getCollateral).toBeDefined(); + expect(typeof api.getCollateral).toBe('function'); + + const collateral = await api.getCollateral(); + expect(collateral).toEqual(null); + expect(await apiWithDeviations.getCollateral()).toEqual([]); + }); }); }); diff --git a/packages/dapp-connector/test/injectGlobal.test.ts b/packages/dapp-connector/test/injectGlobal.test.ts index e5140712422..0019c0a5a72 100644 --- a/packages/dapp-connector/test/injectGlobal.test.ts +++ b/packages/dapp-connector/test/injectGlobal.test.ts @@ -71,5 +71,19 @@ describe('injectGlobal', () => { ]); expect(window.cardano['another-obj']).toBe(anotherObj); }); + + it('injects the wallet public API using custom injection name', () => { + const wallet = new Cip30Wallet(properties, { api, authenticator: stubAuthenticator(), logger }); + injectGlobal(window, wallet, logger, 'customKey'); + expect(window.cardano.customKey.name).toBe(properties.walletName); + expect(Object.keys(window.cardano.customKey)).toEqual([ + 'apiVersion', + 'supportedExtensions', + 'icon', + 'name', + 'enable', + 'isEnabled' + ]); + }); }); });