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
]);