From b3e4754a23ac1ec64705b955c34f8b8d97fc84de Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Sat, 7 Dec 2024 00:50:05 +0100 Subject: [PATCH 1/9] feat: support CAIP294 (standardized messaging transport for browser extension wallets) feat: make sure we pass in extensionId when announcing wallet --- .eslintrc.js | 2 +- jest.config.js | 9 +- src/CAIP294.test.ts | 189 ++++++++++++++++ src/CAIP294.ts | 213 ++++++++++++++++++ src/EIP6963.ts | 17 +- .../createExternalExtensionProvider.ts | 17 +- src/index.ts | 14 ++ src/initializeInpageProvider.ts | 17 +- src/utils.ts | 8 + 9 files changed, 462 insertions(+), 24 deletions(-) create mode 100644 src/CAIP294.test.ts create mode 100644 src/CAIP294.ts diff --git a/.eslintrc.js b/.eslintrc.js index 4e5fb025..3bcf6a66 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -41,7 +41,7 @@ module.exports = { }, { - files: ['EIP6963.test.ts', 'jest.setup.browser.js'], + files: ['EIP6963.test.ts', 'CAIP294.test.ts', 'jest.setup.browser.js'], rules: { // We're mixing Node and browser environments in these files. 'no-restricted-globals': 'off', diff --git a/jest.config.js b/jest.config.js index b43953d4..111dc531 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,10 +45,10 @@ const baseConfig = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 64.65, - functions: 65.65, - lines: 65.51, - statements: 65.61, + branches: 66.94, + functions: 68.18, + lines: 66.17, + statements: 66.25, }, }, @@ -226,6 +226,7 @@ const browserConfig = { '**/*InpageProvider.test.ts', '**/*ExtensionProvider.test.ts', '**/EIP6963.test.ts', + '**/CAIP294.test.ts', ], setupFilesAfterEnv: ['./jest.setup.browser.js'], }; diff --git a/src/CAIP294.test.ts b/src/CAIP294.test.ts new file mode 100644 index 00000000..e7700633 --- /dev/null +++ b/src/CAIP294.test.ts @@ -0,0 +1,189 @@ +import { + announceWallet, + CAIP294EventNames, + type CAIP294WalletData, + requestWallet, +} from './CAIP294'; + +const getWalletData = (): CAIP294WalletData => ({ + uuid: '350670db-19fa-4704-a166-e52e178b59d2', + name: 'Example Wallet', + icon: 'data:image/svg+xml,', + rdns: 'com.example.wallet', + extensionId: 'abcdefghijklmnopqrstuvwxyz', +}); + +const walletDataValidationError = () => + new Error( + `Invalid CAIP-294 WalletData object received from ${CAIP294EventNames.Prompt}. See https://github.com/ChainAgnostic/CAIPs/blob/bc4942857a8e04593ed92f7dc66653577a1c4435/CAIPs/caip-294.md for requirements.`, + ); + +describe('CAIP-294', () => { + describe('wallet data validation', () => { + it('throws if the wallet data is not a plain object', () => { + [null, undefined, Symbol('bar'), []].forEach((invalidInfo) => { + expect(() => announceWallet(invalidInfo as any)).toThrow( + walletDataValidationError(), + ); + }); + }); + + it('throws if the `icon` field is invalid', () => { + [ + null, + undefined, + '', + 'not-a-data-uri', + 'https://example.com/logo.png', + 'data:text/plain;blah', + Symbol('bar'), + ].forEach((invalidIcon) => { + const walletInfo = getWalletData(); + walletInfo.icon = invalidIcon as any; + + expect(() => announceWallet(walletInfo)).toThrow( + walletDataValidationError(), + ); + }); + }); + + it('throws if the `name` field is invalid', () => { + [null, undefined, '', {}, [], Symbol('bar')].forEach((invalidName) => { + const walletInfo = getWalletData(); + walletInfo.name = invalidName as any; + + expect(() => announceWallet(walletInfo)).toThrow( + walletDataValidationError(), + ); + }); + }); + + it('throws if the `uuid` field is invalid', () => { + [null, undefined, '', 'foo', Symbol('bar')].forEach((invalidUuid) => { + const walletInfo = getWalletData(); + walletInfo.uuid = invalidUuid as any; + + expect(() => announceWallet(walletInfo)).toThrow( + walletDataValidationError(), + ); + }); + }); + + it('throws if the `rdns` field is invalid', () => { + [ + null, + undefined, + '', + 'not-a-valid-domain', + '..com', + 'com.', + Symbol('bar'), + ].forEach((invalidRdns) => { + const walletInfo = getWalletData(); + walletInfo.rdns = invalidRdns as any; + + expect(() => announceWallet(walletInfo)).toThrow( + walletDataValidationError(), + ); + }); + }); + + it('allows `extensionId` to be undefined or a string', () => { + const walletInfo = getWalletData(); + expect(() => announceWallet(walletInfo)).not.toThrow(); + + delete walletInfo.extensionId; + + expect(() => announceWallet(walletInfo)).not.toThrow(); + + walletInfo.extensionId = 'valid-string'; + expect(() => announceWallet(walletInfo)).not.toThrow(); + }); + }); + + it('wallet is announced before dapp requests', async () => { + const walletData = getWalletData(); + const handleWallet = jest.fn(); + const dispatchEvent = jest.spyOn(window, 'dispatchEvent'); + const addEventListener = jest.spyOn(window, 'addEventListener'); + + announceWallet(walletData); + requestWallet(handleWallet); + await delay(); + + expect(dispatchEvent).toHaveBeenCalledTimes(3); + expect(dispatchEvent).toHaveBeenNthCalledWith( + 1, + new CustomEvent(CAIP294EventNames.Announce, expect.any(Object)), + ); + expect(dispatchEvent).toHaveBeenNthCalledWith( + 2, + new CustomEvent(CAIP294EventNames.Prompt, expect.any(Object)), + ); + expect(dispatchEvent).toHaveBeenNthCalledWith( + 3, + new CustomEvent(CAIP294EventNames.Announce, expect.any(Object)), + ); + + expect(addEventListener).toHaveBeenCalledTimes(2); + expect(addEventListener).toHaveBeenCalledWith( + CAIP294EventNames.Announce, + expect.any(Function), + ); + expect(addEventListener).toHaveBeenCalledWith( + CAIP294EventNames.Prompt, + expect.any(Function), + ); + + expect(handleWallet).toHaveBeenCalledTimes(1); + expect(handleWallet).toHaveBeenCalledWith( + expect.objectContaining({ params: walletData }), + ); + }); + + it('dapp requests before wallet is announced', async () => { + const walletData = getWalletData(); + const handleWallet = jest.fn(); + const dispatchEvent = jest.spyOn(window, 'dispatchEvent'); + const addEventListener = jest.spyOn(window, 'addEventListener'); + + requestWallet(handleWallet); + announceWallet(walletData); + await delay(); + + expect(dispatchEvent).toHaveBeenCalledTimes(2); + expect(dispatchEvent).toHaveBeenNthCalledWith( + 1, + new CustomEvent(CAIP294EventNames.Prompt, expect.any(Object)), + ); + expect(dispatchEvent).toHaveBeenNthCalledWith( + 2, + new CustomEvent(CAIP294EventNames.Announce, expect.any(Object)), + ); + + expect(addEventListener).toHaveBeenCalledTimes(2); + expect(addEventListener).toHaveBeenCalledWith( + CAIP294EventNames.Announce, + expect.any(Function), + ); + expect(addEventListener).toHaveBeenCalledWith( + CAIP294EventNames.Prompt, + expect.any(Function), + ); + + expect(handleWallet).toHaveBeenCalledTimes(1); + expect(handleWallet).toHaveBeenCalledWith( + expect.objectContaining({ params: walletData }), + ); + }); +}); + +/** + * Delay for a number of milliseconds by awaiting a promise + * resolved after the specified number of milliseconds. + * + * @param ms - The number of milliseconds to delay for. + */ +async function delay(ms = 1) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/CAIP294.ts b/src/CAIP294.ts new file mode 100644 index 00000000..897e07c2 --- /dev/null +++ b/src/CAIP294.ts @@ -0,0 +1,213 @@ +import { isObject } from '@metamask/utils'; + +import { FQDN_REGEX, UUID_V4_REGEX } from './utils'; + +/** + * Describes the possible CAIP-294 event names + */ +export enum CAIP294EventNames { + Announce = 'caip294:wallet_announce', + Prompt = 'caip294:wallet_prompt', +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface WindowEventMap { + [CAIP294EventNames.Prompt]: CAIP294RequestWalletEvent; + [CAIP294EventNames.Announce]: CAIP294AnnounceWalletEvent; + } +} + +/** + * Represents the assets needed to display and identify a wallet. + * @type CAIP294WalletData + * @property uuid - A locally unique identifier for the wallet. MUST be a v4 UUID. + * @property name - The name of the wallet. + * @property icon - The icon for the wallet. MUST be data URI. + * @property rdns - The reverse syntax domain name identifier for the wallet. + * @property extensionId - The canonical extension ID of the wallet provider for the active browser. + */ +export type CAIP294WalletData = { + uuid: string; + name: string; + icon: string; + rdns: string; + extensionId?: string; +}; + +/** + * Event for requesting a wallet. + * + * @type CAIP294RequestWalletEvent + * @property detail - The detail object of the event. + * @property type - The name of the event. + */ +export type CAIP294RequestWalletEvent = CustomEvent & { + detail: { + id: number; + jsonrpc: '2.0'; + method: 'wallet_prompt'; + params: Record; + }; + type: CAIP294EventNames.Prompt; +}; + +/** + * Event for announcing a wallet. + * + * @type CAIP294AnnounceWalletEvent + * @property detail - The detail object of the event. + * @property type - The name of the event. + */ +export type CAIP294AnnounceWalletEvent = CustomEvent & { + detail: { + id: number; + jsonrpc: '2.0'; + method: 'wallet_announce'; + params: CAIP294WalletData; + }; + type: CAIP294EventNames.Announce; +}; + +/** + * Validates an {@link CAIP294RequestWalletEvent} object. + * + * @param event - The {@link CAIP294RequestWalletEvent} to validate. + * @returns Whether the {@link CAIP294RequestWalletEvent} is valid. + */ +function isValidRequestWalletEvent( + event: unknown, +): event is CAIP294RequestWalletEvent { + return ( + event instanceof CustomEvent && + event.type === CAIP294EventNames.Prompt && + isObject(event.detail) && + event.detail.method === 'wallet_prompt' + ); +} + +/** + * Validates an {@link CAIP294AnnounceWalletEvent} object. + * + * @param event - The {@link CAIP294AnnounceWalletEvent} to validate. + * @returns Whether the {@link CAIP294AnnounceWalletEvent} is valid. + */ +function isValidAnnounceWalletEvent( + event: unknown, +): event is CAIP294AnnounceWalletEvent { + return ( + event instanceof CustomEvent && + event.type === CAIP294EventNames.Announce && + isObject(event.detail) && + event.detail.method === 'wallet_announce' && + isValidWalletData(event.detail.params) + ); +} + +/** + * Validates an {@link CAIP294WalletData} object. + * + * @param data - The {@link CAIP294WalletData} to validate. + * @returns Whether the {@link CAIP294WalletData} is valid. + */ +function isValidWalletData(data: unknown): data is CAIP294WalletData { + return ( + isObject(data) && + typeof data.uuid === 'string' && + UUID_V4_REGEX.test(data.uuid) && + typeof data.name === 'string' && + Boolean(data.name) && + typeof data.icon === 'string' && + data.icon.startsWith('data:image') && + typeof data.rdns === 'string' && + FQDN_REGEX.test(data.rdns) && + (data.extensionId === undefined || typeof data.extensionId === 'string') + ); +} + +/** + * Intended to be used by a wallet. Announces a wallet by dispatching + * an {@link CAIP294AnnounceWalletEvent}, and listening for + * {@link CAIP294RequestWalletEvent} to re-announce. + * + * @throws If the {@link CAIP294WalletData} is invalid. + * @param walletData - The {@link CAIP294WalletData} to announce. + */ +export function announceWallet(walletData: CAIP294WalletData): void { + if (!isValidWalletData(walletData)) { + throwErrorCAIP294( + `Invalid CAIP-294 WalletData object received from ${CAIP294EventNames.Prompt}.`, + ); + } + + const _announceWallet = () => + window.dispatchEvent( + new CustomEvent(CAIP294EventNames.Announce, { + detail: { + id: 1, + jsonrpc: '2.0', + method: 'wallet_announce', + params: walletData, + }, + }), + ); + + _announceWallet(); + window.addEventListener( + CAIP294EventNames.Prompt, + (event: CAIP294RequestWalletEvent) => { + if (!isValidRequestWalletEvent(event)) { + throwErrorCAIP294( + `Invalid CAIP-294 RequestWalletEvent object received from ${CAIP294EventNames.Prompt}.`, + ); + } + _announceWallet(); + }, + ); +} + +/** + * Intended to be used by a dapp. Forwards announced wallet to the + * provided handler by listening for * {@link CAIP294AnnounceWalletEvent}, + * and dispatches an {@link CAIP294RequestWalletEvent}. + * + * @param handleWallet - A function that handles an announced wallet. + */ +export function requestWallet( + handleWallet: (walletData: CAIP294WalletData) => HandlerReturnType, +): void { + window.addEventListener( + CAIP294EventNames.Announce, + (event: CAIP294AnnounceWalletEvent) => { + if (!isValidAnnounceWalletEvent(event)) { + throwErrorCAIP294( + `Invalid CAIP-294 WalletData object received from ${CAIP294EventNames.Announce}.`, + ); + } + handleWallet(event.detail); + }, + ); + + window.dispatchEvent( + new CustomEvent(CAIP294EventNames.Prompt, { + detail: { + id: 1, + jsonrpc: '2.0', + method: 'wallet_prompt', + params: {}, + }, + }), + ); +} + +/** + * Throws an error with link to CAIP-294 specifications. + * + * @param message - The message to include. + * @throws a friendly error with a link to CAIP-294. + */ +function throwErrorCAIP294(message: string) { + throw new Error( + `${message} See https://github.com/ChainAgnostic/CAIPs/blob/bc4942857a8e04593ed92f7dc66653577a1c4435/CAIPs/caip-294.md for requirements.`, + ); +} diff --git a/src/EIP6963.ts b/src/EIP6963.ts index 4311bbe0..fce03577 100644 --- a/src/EIP6963.ts +++ b/src/EIP6963.ts @@ -1,6 +1,8 @@ import { isObject } from '@metamask/utils'; import type { BaseProvider } from './BaseProvider'; +import type { CAIP294WalletData } from './CAIP294'; +import { FQDN_REGEX, UUID_V4_REGEX } from './utils'; /** * Describes the possible EIP-6963 event names @@ -27,12 +29,7 @@ declare global { * @property icon - The icon for the wallet. MUST be data URI. * @property rdns - The reverse syntax domain name identifier for the wallet. */ -export type EIP6963ProviderInfo = { - uuid: string; - name: string; - icon: string; - rdns: string; -}; +export type EIP6963ProviderInfo = Omit; /** * Represents a provider and the information relevant for the dapp. @@ -68,14 +65,6 @@ export type EIP6963AnnounceProviderEvent = CustomEvent & { detail: EIP6963ProviderDetail; }; -// https://github.com/thenativeweb/uuidv4/blob/bdcf3a3138bef4fb7c51f389a170666f9012c478/lib/uuidv4.ts#L5 -const UUID_V4_REGEX = - /(?:^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}$)|(?:^0{8}-0{4}-0{4}-0{4}-0{12}$)/u; - -// https://stackoverflow.com/a/20204811 -const FQDN_REGEX = - /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)/u; - /** * Intended to be used by a dapp. Forwards every announced provider to the * provided handler by listening for * {@link EIP6963AnnounceProviderEvent}, diff --git a/src/extension-provider/createExternalExtensionProvider.ts b/src/extension-provider/createExternalExtensionProvider.ts index 4d2bf215..e4a264f3 100644 --- a/src/extension-provider/createExternalExtensionProvider.ts +++ b/src/extension-provider/createExternalExtensionProvider.ts @@ -52,7 +52,7 @@ export function createExternalExtensionProvider( * @param typeOrId - The extension type or ID. * @returns The extension ID. */ -function getExtensionId(typeOrId: ExtensionType) { +export function getExtensionId(typeOrId: ExtensionType) { let ids: { stable: string; beta?: string; @@ -72,3 +72,18 @@ function getExtensionId(typeOrId: ExtensionType) { return ids[typeOrId as keyof typeof ids] ?? typeOrId; } + +/** + * Gets the type or id for the given build name. + * + * @param rdns - The reverse syntax domain name identifier for the wallet. + * @returns The type or ID. + */ +export function getTypeOrId(rdns: string): string { + const rndsToIdDefinition: Record = { + 'io.metamask': 'stable', + 'io.metamask.beta': 'beta', + 'io.metamask.flask': 'flask', + }; + return rndsToIdDefinition[rdns] ?? 'stable'; +} diff --git a/src/index.ts b/src/index.ts index 7a6ef379..5ce13248 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,14 @@ import { BaseProvider } from './BaseProvider'; import type { RequestArguments } from './BaseProvider'; +import type { + CAIP294AnnounceWalletEvent, + CAIP294WalletData, + CAIP294RequestWalletEvent, +} from './CAIP294'; +import { + announceWallet as caip294AnnounceWallet, + requestWallet as caip294RequestWallet, +} from './CAIP294'; import type { EIP6963AnnounceProviderEvent, EIP6963ProviderDetail, @@ -30,6 +39,9 @@ export type { EIP6963ProviderDetail, EIP6963ProviderInfo, EIP6963RequestProviderEvent, + CAIP294AnnounceWalletEvent, + CAIP294WalletData as CAIP294WalletInfo, + CAIP294RequestWalletEvent, }; export { @@ -43,4 +55,6 @@ export { StreamProvider, eip6963AnnounceProvider, eip6963RequestProvider, + caip294AnnounceWallet, + caip294RequestWallet, }; diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index 3fba9600..5d24d9c1 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -1,7 +1,12 @@ import type { Duplex } from 'readable-stream'; -import type { EIP6963ProviderInfo } from './EIP6963'; +import type { CAIP294WalletData } from './CAIP294'; +import { announceWallet } from './CAIP294'; import { announceProvider } from './EIP6963'; +import { + getExtensionId, + getTypeOrId, +} from './extension-provider/createExternalExtensionProvider'; import type { MetaMaskInpageProviderOptions } from './MetaMaskInpageProvider'; import { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; import { shimWeb3 } from './shimWeb3'; @@ -13,9 +18,9 @@ type InitializeProviderOptions = { connectionStream: Duplex; /** - * The EIP-6963 provider info that should be announced if set. + * The EIP-6963 provider info / CAIP-294 wallet data that should be announced if set. */ - providerInfo?: EIP6963ProviderInfo; + providerInfo?: CAIP294WalletData; /** * Whether the provider should be set as window.ethereum. @@ -35,7 +40,7 @@ type InitializeProviderOptions = { * @param options.connectionStream - A Node.js stream. * @param options.jsonRpcStreamName - The name of the internal JSON-RPC stream. * @param options.maxEventListeners - The maximum number of event listeners. - * @param options.providerInfo - The EIP-6963 provider info that should be announced if set. + * @param options.providerInfo - The EIP-6963 provider info / CAIP-294 wallet data that should be announced if set. * @param options.shouldSendMetadata - Whether the provider should send page metadata. * @param options.shouldSetOnWindow - Whether the provider should be set as window.ethereum. * @param options.shouldShimWeb3 - Whether a window.web3 shim should be injected. @@ -70,6 +75,10 @@ export function initializeProvider({ }); if (providerInfo) { + // TODO: Bring up with Jiexi & Alex, if externally_connectable isn't defined, we do not want these CAIP-294 announcements to be made, + const typeOrId = getTypeOrId(providerInfo.rdns); + const extensionId = getExtensionId(typeOrId); + announceWallet({ extensionId, ...providerInfo }); announceProvider({ info: providerInfo, provider: proxiedProvider, diff --git a/src/utils.ts b/src/utils.ts index d04bb4e1..8f9cae29 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -18,6 +18,14 @@ export type ConsoleLike = Pick< // Constants +// https://github.com/thenativeweb/uuidv4/blob/bdcf3a3138bef4fb7c51f389a170666f9012c478/lib/uuidv4.ts#L5 +export const UUID_V4_REGEX = + /(?:^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}$)|(?:^0{8}-0{4}-0{4}-0{4}-0{12}$)/u; + +// https://stackoverflow.com/a/20204811 +export const FQDN_REGEX = + /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)/u; + export const EMITTED_NOTIFICATIONS = Object.freeze([ 'eth_subscription', // per eth-json-rpc-filters/subscriptionManager ]); From 3a1eb6c56498993392b2975f6c3497e2d7492bfc Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Tue, 10 Dec 2024 20:17:35 +0100 Subject: [PATCH 2/9] refactor: address caip294 announcement logic --- src/CAIP294.ts | 9 ++--- src/EIP6963.ts | 4 +-- .../createExternalExtensionProvider.ts | 6 ++-- src/initializeInpageProvider.ts | 36 ++++++++++++++----- src/types.ts | 15 ++++++++ 5 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 src/types.ts diff --git a/src/CAIP294.ts b/src/CAIP294.ts index 897e07c2..d39a87e2 100644 --- a/src/CAIP294.ts +++ b/src/CAIP294.ts @@ -1,5 +1,6 @@ import { isObject } from '@metamask/utils'; +import type { BaseProviderInfo } from './types'; import { FQDN_REGEX, UUID_V4_REGEX } from './utils'; /** @@ -27,12 +28,8 @@ declare global { * @property rdns - The reverse syntax domain name identifier for the wallet. * @property extensionId - The canonical extension ID of the wallet provider for the active browser. */ -export type CAIP294WalletData = { - uuid: string; - name: string; - icon: string; - rdns: string; - extensionId?: string; +export type CAIP294WalletData = BaseProviderInfo & { + extensionId?: string | undefined; }; /** diff --git a/src/EIP6963.ts b/src/EIP6963.ts index fce03577..4ef03fe3 100644 --- a/src/EIP6963.ts +++ b/src/EIP6963.ts @@ -1,7 +1,7 @@ import { isObject } from '@metamask/utils'; import type { BaseProvider } from './BaseProvider'; -import type { CAIP294WalletData } from './CAIP294'; +import type { BaseProviderInfo } from './types'; import { FQDN_REGEX, UUID_V4_REGEX } from './utils'; /** @@ -29,7 +29,7 @@ declare global { * @property icon - The icon for the wallet. MUST be data URI. * @property rdns - The reverse syntax domain name identifier for the wallet. */ -export type EIP6963ProviderInfo = Omit; +export type EIP6963ProviderInfo = BaseProviderInfo; /** * Represents a provider and the information relevant for the dapp. diff --git a/src/extension-provider/createExternalExtensionProvider.ts b/src/extension-provider/createExternalExtensionProvider.ts index e4a264f3..057bf47a 100644 --- a/src/extension-provider/createExternalExtensionProvider.ts +++ b/src/extension-provider/createExternalExtensionProvider.ts @@ -74,16 +74,16 @@ export function getExtensionId(typeOrId: ExtensionType) { } /** - * Gets the type or id for the given build name. + * Gets the build type for the given domain name identifier. * * @param rdns - The reverse syntax domain name identifier for the wallet. * @returns The type or ID. */ -export function getTypeOrId(rdns: string): string { +export function getBuildType(rdns: string): string | undefined { const rndsToIdDefinition: Record = { 'io.metamask': 'stable', 'io.metamask.beta': 'beta', 'io.metamask.flask': 'flask', }; - return rndsToIdDefinition[rdns] ?? 'stable'; + return rndsToIdDefinition[rdns]; } diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index 5d24d9c1..e165c802 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -1,15 +1,19 @@ +import { type Browser, detect } from 'detect-browser'; import type { Duplex } from 'readable-stream'; import type { CAIP294WalletData } from './CAIP294'; import { announceWallet } from './CAIP294'; -import { announceProvider } from './EIP6963'; +import { announceProvider as announceEip6963Provider } from './EIP6963'; import { getExtensionId, - getTypeOrId, + getBuildType, } from './extension-provider/createExternalExtensionProvider'; import type { MetaMaskInpageProviderOptions } from './MetaMaskInpageProvider'; import { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; import { shimWeb3 } from './shimWeb3'; +import type { BaseProviderInfo } from './types'; + +const browser = detect(); type InitializeProviderOptions = { /** @@ -20,7 +24,7 @@ type InitializeProviderOptions = { /** * The EIP-6963 provider info / CAIP-294 wallet data that should be announced if set. */ - providerInfo?: CAIP294WalletData; + providerInfo?: BaseProviderInfo; /** * Whether the provider should be set as window.ethereum. @@ -75,11 +79,8 @@ export function initializeProvider({ }); if (providerInfo) { - // TODO: Bring up with Jiexi & Alex, if externally_connectable isn't defined, we do not want these CAIP-294 announcements to be made, - const typeOrId = getTypeOrId(providerInfo.rdns); - const extensionId = getExtensionId(typeOrId); - announceWallet({ extensionId, ...providerInfo }); - announceProvider({ + announceCaip294WalletData(providerInfo); + announceEip6963Provider({ info: providerInfo, provider: proxiedProvider, }); @@ -108,3 +109,22 @@ export function setGlobalProvider( (window as Record).ethereum = providerInstance; window.dispatchEvent(new Event('ethereum#initialized')); } + +/** + * Announces caip294 wallet data according to build type and browser. + * For now, should only announce if build type is `flask`. + * `extensionId` is included if browser is NOT `firefox`. + * + * @param providerInfo - The provider info {@link BaseProviderInfo}that should be announced if set. + */ +function announceCaip294WalletData(providerInfo: CAIP294WalletData): void { + const buildType = getBuildType(providerInfo.rdns); + if (buildType !== 'flask') { + return; + } + const extensionId = + (browser?.name as Browser) === 'firefox' + ? undefined + : getExtensionId(buildType); + announceWallet({ extensionId, ...providerInfo }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..424b9fa7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,15 @@ +/** + * Represents the base assets needed to display and identify a wallet. + * + * @type BaseProviderInfo + * @property uuid - A locally unique identifier for the wallet. MUST be a v4 UUID. + * @property name - The name of the wallet. + * @property icon - The icon for the wallet. MUST be data URI. + * @property rdns - The reverse syntax domain name identifier for the wallet. + */ +export type BaseProviderInfo = { + uuid: string; + name: string; + icon: string; + rdns: string; +}; From dd97d6a261879ab268ce1e6a2382709a357da40d Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Tue, 10 Dec 2024 21:02:09 +0100 Subject: [PATCH 3/9] test: add unit test for inpage caip294 announcement func --- .eslintrc.js | 7 ++- jest.config.js | 8 +-- src/initializeInpageProvider.test.ts | 80 ++++++++++++++++++++++++++++ src/initializeInpageProvider.ts | 8 +-- 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 src/initializeInpageProvider.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index 3bcf6a66..38f345b4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -41,7 +41,12 @@ module.exports = { }, { - files: ['EIP6963.test.ts', 'CAIP294.test.ts', 'jest.setup.browser.js'], + files: [ + 'EIP6963.test.ts', + 'CAIP294.test.ts', + 'initializeInpageProvider.test.ts', + 'jest.setup.browser.js', + ], rules: { // We're mixing Node and browser environments in these files. 'no-restricted-globals': 'off', diff --git a/jest.config.js b/jest.config.js index 111dc531..248b1c02 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,10 +45,10 @@ const baseConfig = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 66.94, - functions: 68.18, - lines: 66.17, - statements: 66.25, + branches: 67.9, + functions: 68.46, + lines: 68.69, + statements: 68.72, }, }, diff --git a/src/initializeInpageProvider.test.ts b/src/initializeInpageProvider.test.ts new file mode 100644 index 00000000..440dcd55 --- /dev/null +++ b/src/initializeInpageProvider.test.ts @@ -0,0 +1,80 @@ +import { detect } from 'detect-browser'; + +import { announceWallet, type CAIP294WalletData } from './CAIP294'; +import { + getBuildType, + getExtensionId, +} from './extension-provider/createExternalExtensionProvider'; +import { announceCaip294WalletData } from './initializeInpageProvider'; + +jest.mock('./extension-provider/createExternalExtensionProvider'); +jest.mock('./CAIP294'); +jest.mock('detect-browser'); + +describe('announceCaip294WalletData', () => { + const mockProviderInfo: CAIP294WalletData = { + uuid: '123e4567-e89b-12d3-a456-426614174000', + name: 'Test Wallet', + icon: 'data:image/png;base64,iVBORw0KGgo=', + rdns: 'com.testwallet', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not announce wallet if build type is not flask', () => { + (getBuildType as jest.Mock).mockReturnValue('stable'); + + announceCaip294WalletData(mockProviderInfo); + + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(getExtensionId).not.toHaveBeenCalled(); + expect(announceWallet).not.toHaveBeenCalled(); + }); + + it('should announce wallet with extensionId for non-firefox browsers', () => { + (getBuildType as jest.Mock).mockReturnValue('flask'); + (getExtensionId as jest.Mock).mockReturnValue('test-extension-id'); + // (global as any).browser = { name: 'chrome' }; + (detect as jest.Mock).mockReturnValue({ name: 'chrome' }); + + announceCaip294WalletData(mockProviderInfo); + + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(getExtensionId).toHaveBeenCalledWith('flask'); + expect(announceWallet).toHaveBeenCalledWith({ + ...mockProviderInfo, + extensionId: 'test-extension-id', + }); + }); + + it('should announce wallet without extensionId for firefox browser', () => { + (getBuildType as jest.Mock).mockReturnValue('flask'); + (detect as jest.Mock).mockReturnValue({ name: 'firefox' }); + + announceCaip294WalletData(mockProviderInfo); + + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(getExtensionId).not.toHaveBeenCalled(); + expect(announceWallet).toHaveBeenCalledWith({ + ...mockProviderInfo, + extensionId: undefined, + }); + }); + + it('should handle undefined browser', () => { + (getBuildType as jest.Mock).mockReturnValue('flask'); + (getExtensionId as jest.Mock).mockReturnValue('test-extension-id'); + (detect as jest.Mock).mockReturnValue(undefined); + + announceCaip294WalletData(mockProviderInfo); + + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(getExtensionId).toHaveBeenCalledWith('flask'); + expect(announceWallet).toHaveBeenCalledWith({ + ...mockProviderInfo, + extensionId: 'test-extension-id', + }); + }); +}); diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index e165c802..44dab49a 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -13,8 +13,6 @@ import { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; import { shimWeb3 } from './shimWeb3'; import type { BaseProviderInfo } from './types'; -const browser = detect(); - type InitializeProviderOptions = { /** * The stream used to connect to the wallet. @@ -117,11 +115,15 @@ export function setGlobalProvider( * * @param providerInfo - The provider info {@link BaseProviderInfo}that should be announced if set. */ -function announceCaip294WalletData(providerInfo: CAIP294WalletData): void { +export function announceCaip294WalletData( + providerInfo: CAIP294WalletData, +): void { const buildType = getBuildType(providerInfo.rdns); if (buildType !== 'flask') { return; } + + const browser = detect(); const extensionId = (browser?.name as Browser) === 'firefox' ? undefined From 1a5612448716bd0157be9aae26a2aab17d256444 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Tue, 10 Dec 2024 22:52:19 +0100 Subject: [PATCH 4/9] test: delete commented code --- src/initializeInpageProvider.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/initializeInpageProvider.test.ts b/src/initializeInpageProvider.test.ts index 440dcd55..b6d09d78 100644 --- a/src/initializeInpageProvider.test.ts +++ b/src/initializeInpageProvider.test.ts @@ -36,7 +36,6 @@ describe('announceCaip294WalletData', () => { it('should announce wallet with extensionId for non-firefox browsers', () => { (getBuildType as jest.Mock).mockReturnValue('flask'); (getExtensionId as jest.Mock).mockReturnValue('test-extension-id'); - // (global as any).browser = { name: 'chrome' }; (detect as jest.Mock).mockReturnValue({ name: 'chrome' }); announceCaip294WalletData(mockProviderInfo); From d01b2cecfe2342e342d1292b4ccc6d54bfb2d3ed Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Wed, 11 Dec 2024 10:38:42 +0100 Subject: [PATCH 5/9] refactor: code review changes --- src/initializeInpageProvider.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index 44dab49a..6ea033cc 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -1,4 +1,4 @@ -import { type Browser, detect } from 'detect-browser'; +import { detect } from 'detect-browser'; import type { Duplex } from 'readable-stream'; import type { CAIP294WalletData } from './CAIP294'; @@ -109,11 +109,11 @@ export function setGlobalProvider( } /** - * Announces caip294 wallet data according to build type and browser. - * For now, should only announce if build type is `flask`. - * `extensionId` is included if browser is NOT `firefox`. + * Announces [caip294](https://github.com/ChainAgnostic/CAIPs/blob/bc4942857a8e04593ed92f7dc66653577a1c4435/CAIPs/caip-294.md) wallet data according to build type and browser. + * Until released to stable, only announces if build type is `flask`. + * `extensionId` is included if browser is NOT `firefox` because it is only useable by browsers that support [externally_connectable](https://developer.chrome.com/docs/extensions/reference/manifest/externally-connectable). * - * @param providerInfo - The provider info {@link BaseProviderInfo}that should be announced if set. + * @param providerInfo - The provider info {@link BaseProviderInfo} that should be announced if set. */ export function announceCaip294WalletData( providerInfo: CAIP294WalletData, @@ -124,9 +124,12 @@ export function announceCaip294WalletData( } const browser = detect(); - const extensionId = - (browser?.name as Browser) === 'firefox' - ? undefined - : getExtensionId(buildType); - announceWallet({ extensionId, ...providerInfo }); + const walletData = { + ...providerInfo, + ...(browser?.name !== 'firefox' && { + extensionId: getExtensionId(buildType), + }), + }; + + announceWallet(walletData); } From 48651c55be194d229ba972fe05571e5d81f1c2f9 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Wed, 11 Dec 2024 19:38:50 +0100 Subject: [PATCH 6/9] feat: get extensionId from provider state --- jest.config.js | 8 +-- .../createExternalExtensionProvider.test.ts | 26 ++++++- .../createExternalExtensionProvider.ts | 2 +- src/initializeInpageProvider.test.ts | 71 +++++++++---------- src/initializeInpageProvider.ts | 27 +++---- 5 files changed, 76 insertions(+), 58 deletions(-) diff --git a/jest.config.js b/jest.config.js index 248b1c02..a7bae017 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,10 +45,10 @@ const baseConfig = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 67.9, - functions: 68.46, - lines: 68.69, - statements: 68.72, + branches: 67.63, + functions: 70.27, + lines: 69.47, + statements: 69.48, }, }, diff --git a/src/extension-provider/createExternalExtensionProvider.test.ts b/src/extension-provider/createExternalExtensionProvider.test.ts index 87ce1489..42c9e60e 100644 --- a/src/extension-provider/createExternalExtensionProvider.test.ts +++ b/src/extension-provider/createExternalExtensionProvider.test.ts @@ -1,6 +1,9 @@ import type { JsonRpcRequest } from '@metamask/utils'; -import { createExternalExtensionProvider } from './createExternalExtensionProvider'; +import { + createExternalExtensionProvider, + getBuildType, +} from './createExternalExtensionProvider'; import config from './external-extension-config.json'; import { MockPort } from '../../test/mocks/MockPort'; import type { BaseProvider } from '../BaseProvider'; @@ -97,6 +100,27 @@ async function getInitializedProvider({ return { provider, port, onWrite }; } +describe('getBuildType', () => { + it('should return `beta` if payload is beta reverse syntax domain name', () => { + const payload = 'io.metamask.beta'; + const result = getBuildType(payload); + + expect(result).toBe('beta'); + }); + it('should return `stable` if payload is production reverse syntax domain name', () => { + const payload = 'io.metamask'; + const result = getBuildType(payload); + + expect(result).toBe('stable'); + }); + it('should return `flask` if payload is flask reverse syntax domain name', () => { + const payload = 'io.metamask.flask'; + const result = getBuildType(payload); + + expect(result).toBe('flask'); + }); +}); + describe('createExternalExtensionProvider', () => { it('can be called and not throw', () => { // `global.chrome.runtime` mock setup by `jest-chrome` in `jest.setup.browser.js` diff --git a/src/extension-provider/createExternalExtensionProvider.ts b/src/extension-provider/createExternalExtensionProvider.ts index 057bf47a..b8bd9171 100644 --- a/src/extension-provider/createExternalExtensionProvider.ts +++ b/src/extension-provider/createExternalExtensionProvider.ts @@ -52,7 +52,7 @@ export function createExternalExtensionProvider( * @param typeOrId - The extension type or ID. * @returns The extension ID. */ -export function getExtensionId(typeOrId: ExtensionType) { +function getExtensionId(typeOrId: ExtensionType) { let ids: { stable: string; beta?: string; diff --git a/src/initializeInpageProvider.test.ts b/src/initializeInpageProvider.test.ts index b6d09d78..0533b98d 100644 --- a/src/initializeInpageProvider.test.ts +++ b/src/initializeInpageProvider.test.ts @@ -1,17 +1,31 @@ -import { detect } from 'detect-browser'; - import { announceWallet, type CAIP294WalletData } from './CAIP294'; +import { getBuildType } from './extension-provider/createExternalExtensionProvider'; import { - getBuildType, - getExtensionId, -} from './extension-provider/createExternalExtensionProvider'; -import { announceCaip294WalletData } from './initializeInpageProvider'; + announceCaip294WalletData, + setGlobalProvider, +} from './initializeInpageProvider'; +import type { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; jest.mock('./extension-provider/createExternalExtensionProvider'); jest.mock('./CAIP294'); -jest.mock('detect-browser'); + +describe('setGlobalProvider', () => { + it('should call addEventListener once', () => { + const mockProvider = {} as unknown as MetaMaskInpageProvider; + const dispatchEvent = jest.spyOn(window, 'dispatchEvent'); + setGlobalProvider(mockProvider); + + expect(dispatchEvent).toHaveBeenCalledTimes(1); + expect(dispatchEvent).toHaveBeenCalledWith( + new Event('ethereum#initialized'), + ); + }); +}); describe('announceCaip294WalletData', () => { + const mockProvider = { + request: jest.fn(), + } as unknown as MetaMaskInpageProvider; const mockProviderInfo: CAIP294WalletData = { uuid: '123e4567-e89b-12d3-a456-426614174000', name: 'Test Wallet', @@ -23,57 +37,36 @@ describe('announceCaip294WalletData', () => { jest.clearAllMocks(); }); - it('should not announce wallet if build type is not flask', () => { + it('should not announce wallet if build type is not flask', async () => { (getBuildType as jest.Mock).mockReturnValue('stable'); - announceCaip294WalletData(mockProviderInfo); + await announceCaip294WalletData(mockProvider, mockProviderInfo); expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); - expect(getExtensionId).not.toHaveBeenCalled(); expect(announceWallet).not.toHaveBeenCalled(); }); - it('should announce wallet with extensionId for non-firefox browsers', () => { + it('should announce wallet with extensionId for non-firefox browsers', async () => { + const extensionId = 'test-extension-id'; (getBuildType as jest.Mock).mockReturnValue('flask'); - (getExtensionId as jest.Mock).mockReturnValue('test-extension-id'); - (detect as jest.Mock).mockReturnValue({ name: 'chrome' }); + (mockProvider.request as jest.Mock).mockReturnValue({ extensionId }); - announceCaip294WalletData(mockProviderInfo); + await announceCaip294WalletData(mockProvider, mockProviderInfo); expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); - expect(getExtensionId).toHaveBeenCalledWith('flask'); expect(announceWallet).toHaveBeenCalledWith({ ...mockProviderInfo, - extensionId: 'test-extension-id', + extensionId, }); }); - it('should announce wallet without extensionId for firefox browser', () => { + it('should announce wallet without extensionId for firefox browser', async () => { (getBuildType as jest.Mock).mockReturnValue('flask'); - (detect as jest.Mock).mockReturnValue({ name: 'firefox' }); + (mockProvider.request as jest.Mock).mockReturnValue({}); - announceCaip294WalletData(mockProviderInfo); + await announceCaip294WalletData(mockProvider, mockProviderInfo); expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); - expect(getExtensionId).not.toHaveBeenCalled(); - expect(announceWallet).toHaveBeenCalledWith({ - ...mockProviderInfo, - extensionId: undefined, - }); - }); - - it('should handle undefined browser', () => { - (getBuildType as jest.Mock).mockReturnValue('flask'); - (getExtensionId as jest.Mock).mockReturnValue('test-extension-id'); - (detect as jest.Mock).mockReturnValue(undefined); - - announceCaip294WalletData(mockProviderInfo); - - expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); - expect(getExtensionId).toHaveBeenCalledWith('flask'); - expect(announceWallet).toHaveBeenCalledWith({ - ...mockProviderInfo, - extensionId: 'test-extension-id', - }); + expect(announceWallet).toHaveBeenCalledWith(mockProviderInfo); }); }); diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index 6ea033cc..b3f36297 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -1,13 +1,9 @@ -import { detect } from 'detect-browser'; import type { Duplex } from 'readable-stream'; import type { CAIP294WalletData } from './CAIP294'; import { announceWallet } from './CAIP294'; import { announceProvider as announceEip6963Provider } from './EIP6963'; -import { - getExtensionId, - getBuildType, -} from './extension-provider/createExternalExtensionProvider'; +import { getBuildType } from './extension-provider/createExternalExtensionProvider'; import type { MetaMaskInpageProviderOptions } from './MetaMaskInpageProvider'; import { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; import { shimWeb3 } from './shimWeb3'; @@ -77,11 +73,12 @@ export function initializeProvider({ }); if (providerInfo) { - announceCaip294WalletData(providerInfo); announceEip6963Provider({ info: providerInfo, provider: proxiedProvider, }); + // eslint-disable-next-line no-void + void announceCaip294WalletData(provider, providerInfo); } if (shouldSetOnWindow) { @@ -110,25 +107,29 @@ export function setGlobalProvider( /** * Announces [caip294](https://github.com/ChainAgnostic/CAIPs/blob/bc4942857a8e04593ed92f7dc66653577a1c4435/CAIPs/caip-294.md) wallet data according to build type and browser. - * Until released to stable, only announces if build type is `flask`. + * Until released to stable, `extensionId` is only retrieved from provider state if build type is `flask`. * `extensionId` is included if browser is NOT `firefox` because it is only useable by browsers that support [externally_connectable](https://developer.chrome.com/docs/extensions/reference/manifest/externally-connectable). * + * @param provider - The provider {@link MetaMaskInpageProvider} used for retrieving `extensionId`. * @param providerInfo - The provider info {@link BaseProviderInfo} that should be announced if set. */ -export function announceCaip294WalletData( +export async function announceCaip294WalletData( + provider: MetaMaskInpageProvider, providerInfo: CAIP294WalletData, -): void { +): Promise { const buildType = getBuildType(providerInfo.rdns); if (buildType !== 'flask') { return; } - const browser = detect(); + const providerState = await provider.request<{ extensionId: string }>({ + method: 'metamask_getProviderState', + }); + const extensionId = providerState?.extensionId; + const walletData = { ...providerInfo, - ...(browser?.name !== 'firefox' && { - extensionId: getExtensionId(buildType), - }), + extensionId, }; announceWallet(walletData); From 66332cf3f3dace9d1a48720e6524b71a409d6ba0 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Wed, 11 Dec 2024 20:55:13 +0100 Subject: [PATCH 7/9] refactor: minor test clean up --- .../createExternalExtensionProvider.test.ts | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/extension-provider/createExternalExtensionProvider.test.ts b/src/extension-provider/createExternalExtensionProvider.test.ts index 42c9e60e..dff2ee23 100644 --- a/src/extension-provider/createExternalExtensionProvider.test.ts +++ b/src/extension-provider/createExternalExtensionProvider.test.ts @@ -99,26 +99,21 @@ async function getInitializedProvider({ return { provider, port, onWrite }; } - describe('getBuildType', () => { - it('should return `beta` if payload is beta reverse syntax domain name', () => { - const payload = 'io.metamask.beta'; - const result = getBuildType(payload); - - expect(result).toBe('beta'); - }); - it('should return `stable` if payload is production reverse syntax domain name', () => { - const payload = 'io.metamask'; - const result = getBuildType(payload); + const testCases = [ + { payload: 'io.metamask.beta', expected: 'beta' }, + { payload: 'io.metamask', expected: 'stable' }, + { payload: 'io.metamask.flask', expected: 'flask' }, + { payload: 'io.metamask.unknown', expected: undefined }, + ]; - expect(result).toBe('stable'); - }); - it('should return `flask` if payload is flask reverse syntax domain name', () => { - const payload = 'io.metamask.flask'; - const result = getBuildType(payload); - - expect(result).toBe('flask'); - }); + it.each(testCases)( + 'should return $expected for payload $payload', + ({ payload, expected }) => { + const result = getBuildType(payload); + expect(result).toBe(expected); + }, + ); }); describe('createExternalExtensionProvider', () => { From 07abf086656e8e45aa887ce7fb3a37c7f16d350d Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Thu, 12 Dec 2024 21:43:14 +0100 Subject: [PATCH 8/9] refactor: improvement suggestions from jiexi --- jest.config.js | 8 ++--- src/CAIP294.test.ts | 11 +++++++ src/CAIP294.ts | 24 +++++++++++++-- src/initializeInpageProvider.test.ts | 46 +++++++++++++++------------- src/initializeInpageProvider.ts | 6 ++-- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/jest.config.js b/jest.config.js index a7bae017..f73183f3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,10 +45,10 @@ const baseConfig = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 67.63, - functions: 70.27, - lines: 69.47, - statements: 69.48, + branches: 67.6, + functions: 69.91, + lines: 69.51, + statements: 69.52, }, }, diff --git a/src/CAIP294.test.ts b/src/CAIP294.test.ts index e7700633..cdd15f6f 100644 --- a/src/CAIP294.test.ts +++ b/src/CAIP294.test.ts @@ -101,6 +101,17 @@ describe('CAIP-294', () => { }); }); + it('throws if the `extensionId` field is invalid', () => { + [null, '', 42, Symbol('bar')].forEach((invalidExtensionId) => { + const walletInfo = getWalletData(); + walletInfo.extensionId = invalidExtensionId as any; + + expect(() => announceWallet(walletInfo)).toThrow( + walletDataValidationError(), + ); + }); + }); + it('wallet is announced before dapp requests', async () => { const walletData = getWalletData(); const handleWallet = jest.fn(); diff --git a/src/CAIP294.ts b/src/CAIP294.ts index d39a87e2..090bd437 100644 --- a/src/CAIP294.ts +++ b/src/CAIP294.ts @@ -79,10 +79,29 @@ function isValidRequestWalletEvent( event instanceof CustomEvent && event.type === CAIP294EventNames.Prompt && isObject(event.detail) && - event.detail.method === 'wallet_prompt' + event.detail.method === 'wallet_prompt' && + isValidWalletPromptParams(event.detail.params) ); } +/** + * Validates a {@link CAIP294RequestWalletEvent} params field. + * + * @param params - The parameters to validate. + * @returns Whether the parameters are valid. + */ +function isValidWalletPromptParams(params: any): params is Record { + const isValidChains = + params.chains === undefined || + (Array.isArray(params.chains) && + params.chains.every((chain: any) => typeof chain === 'string')); + + const isValidAuthName = + params.authName === undefined || typeof params.authName === 'string'; + + return isValidChains && isValidAuthName; +} + /** * Validates an {@link CAIP294AnnounceWalletEvent} object. * @@ -118,7 +137,8 @@ function isValidWalletData(data: unknown): data is CAIP294WalletData { data.icon.startsWith('data:image') && typeof data.rdns === 'string' && FQDN_REGEX.test(data.rdns) && - (data.extensionId === undefined || typeof data.extensionId === 'string') + (data.extensionId === undefined || + (typeof data.extensionId === 'string' && data.extensionId.length > 0)) ); } diff --git a/src/initializeInpageProvider.test.ts b/src/initializeInpageProvider.test.ts index 0533b98d..6c06afe0 100644 --- a/src/initializeInpageProvider.test.ts +++ b/src/initializeInpageProvider.test.ts @@ -37,36 +37,40 @@ describe('announceCaip294WalletData', () => { jest.clearAllMocks(); }); - it('should not announce wallet if build type is not flask', async () => { - (getBuildType as jest.Mock).mockReturnValue('stable'); + describe('build type is not flask', () => { + it('should not announce wallet if build type is not flask', async () => { + (getBuildType as jest.Mock).mockReturnValue('stable'); - await announceCaip294WalletData(mockProvider, mockProviderInfo); + await announceCaip294WalletData(mockProvider, mockProviderInfo); - expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); - expect(announceWallet).not.toHaveBeenCalled(); + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(announceWallet).not.toHaveBeenCalled(); + }); }); - it('should announce wallet with extensionId for non-firefox browsers', async () => { - const extensionId = 'test-extension-id'; - (getBuildType as jest.Mock).mockReturnValue('flask'); - (mockProvider.request as jest.Mock).mockReturnValue({ extensionId }); + describe('build type is flask', () => { + it('should announce wallet with extensionId for non-firefox browsers', async () => { + const extensionId = 'test-extension-id'; + (getBuildType as jest.Mock).mockReturnValue('flask'); + (mockProvider.request as jest.Mock).mockReturnValue({ extensionId }); - await announceCaip294WalletData(mockProvider, mockProviderInfo); + await announceCaip294WalletData(mockProvider, mockProviderInfo); - expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); - expect(announceWallet).toHaveBeenCalledWith({ - ...mockProviderInfo, - extensionId, + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(announceWallet).toHaveBeenCalledWith({ + ...mockProviderInfo, + extensionId, + }); }); - }); - it('should announce wallet without extensionId for firefox browser', async () => { - (getBuildType as jest.Mock).mockReturnValue('flask'); - (mockProvider.request as jest.Mock).mockReturnValue({}); + it('should announce wallet without extensionId for firefox browser', async () => { + (getBuildType as jest.Mock).mockReturnValue('flask'); + (mockProvider.request as jest.Mock).mockReturnValue({}); - await announceCaip294WalletData(mockProvider, mockProviderInfo); + await announceCaip294WalletData(mockProvider, mockProviderInfo); - expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); - expect(announceWallet).toHaveBeenCalledWith(mockProviderInfo); + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(announceWallet).toHaveBeenCalledWith(mockProviderInfo); + }); }); }); diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index b3f36297..be92baba 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -106,9 +106,9 @@ export function setGlobalProvider( } /** - * Announces [caip294](https://github.com/ChainAgnostic/CAIPs/blob/bc4942857a8e04593ed92f7dc66653577a1c4435/CAIPs/caip-294.md) wallet data according to build type and browser. - * Until released to stable, `extensionId` is only retrieved from provider state if build type is `flask`. - * `extensionId` is included if browser is NOT `firefox` because it is only useable by browsers that support [externally_connectable](https://developer.chrome.com/docs/extensions/reference/manifest/externally-connectable). + * Announces [CAIP-294](https://github.com/ChainAgnostic/CAIPs/blob/bc4942857a8e04593ed92f7dc66653577a1c4435/CAIPs/caip-294.md) wallet data according to build type and browser. + * Until released to stable, `extensionId` is only set in the `metamask_getProviderState` result if the build type is `flask`. + * `extensionId` is included if browser is chromium based because it is only useable by browsers that support [externally_connectable](https://developer.chrome.com/docs/extensions/reference/manifest/externally-connectable). * * @param provider - The provider {@link MetaMaskInpageProvider} used for retrieving `extensionId`. * @param providerInfo - The provider info {@link BaseProviderInfo} that should be announced if set. From 06e9771264e9bbfe9ccf74e0484dad41dff86a49 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 13 Dec 2024 10:36:46 +0100 Subject: [PATCH 9/9] refactor: minor fix --- src/initializeInpageProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index be92baba..91d2a1ed 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -122,7 +122,7 @@ export async function announceCaip294WalletData( return; } - const providerState = await provider.request<{ extensionId: string }>({ + const providerState = await provider.request<{ extensionId?: string }>({ method: 'metamask_getProviderState', }); const extensionId = providerState?.extensionId;