diff --git a/.eslintrc.js b/.eslintrc.js index 4e5fb025..38f345b4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -41,7 +41,12 @@ module.exports = { }, { - files: ['EIP6963.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 b43953d4..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: 64.65, - functions: 65.65, - lines: 65.51, - statements: 65.61, + branches: 67.6, + functions: 69.91, + lines: 69.51, + statements: 69.52, }, }, @@ -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..cdd15f6f --- /dev/null +++ b/src/CAIP294.test.ts @@ -0,0 +1,200 @@ +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('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(); + 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..090bd437 --- /dev/null +++ b/src/CAIP294.ts @@ -0,0 +1,230 @@ +import { isObject } from '@metamask/utils'; + +import type { BaseProviderInfo } from './types'; +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 = BaseProviderInfo & { + extensionId?: string | undefined; +}; + +/** + * 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' && + 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. + * + * @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' && data.extensionId.length > 0)) + ); +} + +/** + * 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..4ef03fe3 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 { BaseProviderInfo } from './types'; +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 = BaseProviderInfo; /** * 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.test.ts b/src/extension-provider/createExternalExtensionProvider.test.ts index 87ce1489..dff2ee23 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'; @@ -96,6 +99,22 @@ async function getInitializedProvider({ return { provider, port, onWrite }; } +describe('getBuildType', () => { + 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 }, + ]; + + it.each(testCases)( + 'should return $expected for payload $payload', + ({ payload, expected }) => { + const result = getBuildType(payload); + expect(result).toBe(expected); + }, + ); +}); describe('createExternalExtensionProvider', () => { it('can be called and not throw', () => { diff --git a/src/extension-provider/createExternalExtensionProvider.ts b/src/extension-provider/createExternalExtensionProvider.ts index 4d2bf215..b8bd9171 100644 --- a/src/extension-provider/createExternalExtensionProvider.ts +++ b/src/extension-provider/createExternalExtensionProvider.ts @@ -72,3 +72,18 @@ function getExtensionId(typeOrId: ExtensionType) { return ids[typeOrId as keyof typeof ids] ?? typeOrId; } + +/** + * 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 getBuildType(rdns: string): string | undefined { + const rndsToIdDefinition: Record = { + 'io.metamask': 'stable', + 'io.metamask.beta': 'beta', + 'io.metamask.flask': 'flask', + }; + return rndsToIdDefinition[rdns]; +} 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.test.ts b/src/initializeInpageProvider.test.ts new file mode 100644 index 00000000..6c06afe0 --- /dev/null +++ b/src/initializeInpageProvider.test.ts @@ -0,0 +1,76 @@ +import { announceWallet, type CAIP294WalletData } from './CAIP294'; +import { getBuildType } from './extension-provider/createExternalExtensionProvider'; +import { + announceCaip294WalletData, + setGlobalProvider, +} from './initializeInpageProvider'; +import type { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; + +jest.mock('./extension-provider/createExternalExtensionProvider'); +jest.mock('./CAIP294'); + +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', + icon: 'data:image/png;base64,iVBORw0KGgo=', + rdns: 'com.testwallet', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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); + + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(announceWallet).not.toHaveBeenCalled(); + }); + }); + + 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); + + 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({}); + + await announceCaip294WalletData(mockProvider, mockProviderInfo); + + expect(getBuildType).toHaveBeenCalledWith(mockProviderInfo.rdns); + expect(announceWallet).toHaveBeenCalledWith(mockProviderInfo); + }); + }); +}); diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index 3fba9600..91d2a1ed 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -1,10 +1,13 @@ import type { Duplex } from 'readable-stream'; -import type { EIP6963ProviderInfo } from './EIP6963'; -import { announceProvider } from './EIP6963'; +import type { CAIP294WalletData } from './CAIP294'; +import { announceWallet } from './CAIP294'; +import { announceProvider as announceEip6963Provider } from './EIP6963'; +import { getBuildType } from './extension-provider/createExternalExtensionProvider'; import type { MetaMaskInpageProviderOptions } from './MetaMaskInpageProvider'; import { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; import { shimWeb3 } from './shimWeb3'; +import type { BaseProviderInfo } from './types'; type InitializeProviderOptions = { /** @@ -13,9 +16,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?: BaseProviderInfo; /** * Whether the provider should be set as window.ethereum. @@ -35,7 +38,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,10 +73,12 @@ export function initializeProvider({ }); if (providerInfo) { - announceProvider({ + announceEip6963Provider({ info: providerInfo, provider: proxiedProvider, }); + // eslint-disable-next-line no-void + void announceCaip294WalletData(provider, providerInfo); } if (shouldSetOnWindow) { @@ -99,3 +104,33 @@ export function setGlobalProvider( (window as Record).ethereum = providerInstance; window.dispatchEvent(new Event('ethereum#initialized')); } + +/** + * 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. + */ +export async function announceCaip294WalletData( + provider: MetaMaskInpageProvider, + providerInfo: CAIP294WalletData, +): Promise { + const buildType = getBuildType(providerInfo.rdns); + if (buildType !== 'flask') { + return; + } + + const providerState = await provider.request<{ extensionId?: string }>({ + method: 'metamask_getProviderState', + }); + const extensionId = providerState?.extensionId; + + const walletData = { + ...providerInfo, + extensionId, + }; + + announceWallet(walletData); +} 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; +}; 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 ]);