From b6c325d1e368bf96ef81f8f605ac89ce68b117af Mon Sep 17 00:00:00 2001 From: Omri Dan <61094771+omridan159@users.noreply.github.com> Date: Tue, 24 Oct 2023 13:17:15 +0300 Subject: [PATCH] feat: add support to EIP-6963 protocol (#423) * feat: add support to eip6963 protocol * feat: fix PR comments * feat: fix PR comments * feat: fix unit tests * fix: the timeout logic * fix: pr comments * fix: pr comments * fix: tests --- packages/sdk/src/constants.ts | 8 +++ .../performSDKInitialization.test.ts | 2 +- .../setupExtensionPreferences.ts | 3 +- .../src/utils/eip6963RequestProvider.test.ts | 63 ++++++++++++++++ .../sdk/src/utils/eip6963RequestProvider.ts | 57 +++++++++++++++ .../src/utils/get-browser-extension.test.ts | 71 +++++++++++++------ .../sdk/src/utils/get-browser-extension.ts | 59 +++++++++------ .../sdk/src/utils/shouldInjectProvider.ts | 2 +- 8 files changed, 220 insertions(+), 45 deletions(-) create mode 100644 packages/sdk/src/utils/eip6963RequestProvider.test.ts create mode 100644 packages/sdk/src/utils/eip6963RequestProvider.ts diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index d45b1b450..d304fadb4 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -12,5 +12,13 @@ export const METAMASK_CONNECT_BASE_URL = 'https://metamask.app.link/connect'; export const METAMASK_DEEPLINK_BASE = 'metamask://connect'; +export const METAMASK_EIP_6369_PROVIDER_INFO = { + NAME: 'MetaMask Main', + RDNS: 'io.metamask', +}; + +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; + export const ONE_MINUTE_IN_MS = 60 * 1000; export const ONE_HOUR_IN_MS = ONE_MINUTE_IN_MS * 60; diff --git a/packages/sdk/src/services/MetaMaskSDK/InitializerManager/performSDKInitialization.test.ts b/packages/sdk/src/services/MetaMaskSDK/InitializerManager/performSDKInitialization.test.ts index 502ee9f26..7de9ec141 100644 --- a/packages/sdk/src/services/MetaMaskSDK/InitializerManager/performSDKInitialization.test.ts +++ b/packages/sdk/src/services/MetaMaskSDK/InitializerManager/performSDKInitialization.test.ts @@ -38,7 +38,7 @@ describe('performSDKInitialization', () => { mockSetupExtensionPreferencesReturnValue = { shouldReturn: false, preferExtension: false, - metamaskBrowserExtension: null, + metamaskBrowserExtension: undefined, }; mockSetupExtensionPreferences.mockImplementation(() => { diff --git a/packages/sdk/src/services/MetaMaskSDK/InitializerManager/setupExtensionPreferences.ts b/packages/sdk/src/services/MetaMaskSDK/InitializerManager/setupExtensionPreferences.ts index 02079c8e0..033cff41e 100644 --- a/packages/sdk/src/services/MetaMaskSDK/InitializerManager/setupExtensionPreferences.ts +++ b/packages/sdk/src/services/MetaMaskSDK/InitializerManager/setupExtensionPreferences.ts @@ -39,9 +39,10 @@ export async function setupExtensionPreferences(instance: MetaMaskSDK) { localStorage.getItem(STORAGE_PROVIDER_TYPE) === 'extension'; try { - metamaskBrowserExtension = getBrowserExtension({ + metamaskBrowserExtension = await getBrowserExtension({ mustBeMetaMask: true, }); + window.extension = metamaskBrowserExtension; } catch (err) { // Ignore error if metamask extension not found diff --git a/packages/sdk/src/utils/eip6963RequestProvider.test.ts b/packages/sdk/src/utils/eip6963RequestProvider.test.ts new file mode 100644 index 000000000..d317a39cb --- /dev/null +++ b/packages/sdk/src/utils/eip6963RequestProvider.test.ts @@ -0,0 +1,63 @@ +import { SDKProvider } from '../provider/SDKProvider'; +import { + EIP6963EventNames, + EIP6963ProviderDetail, + EIP6963ProviderInfo, + eip6963RequestProvider, +} from './eip6963RequestProvider'; + +describe('eip6963RequestProvider', () => { + beforeEach(() => { + global.window = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + } as any; + + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should reject after timeout', async () => { + const requestProviderPromise = eip6963RequestProvider(); + + jest.advanceTimersByTime(501); + + await expect(requestProviderPromise).rejects.toThrow( + 'eip6963RequestProvider timed out', + ); + }); + + it('should resolve with valid provider', async () => { + const mockProvider: SDKProvider = {} as SDKProvider; + const mockInfo: EIP6963ProviderInfo = { + uuid: 'a1d2c588-106f-4d05-958d-5e7d6c57c822', + name: 'MetaMask Main', + icon: 'icon-path', + rdns: 'io.metamask', + }; + const mockEventDetail: EIP6963ProviderDetail = { + info: mockInfo, + provider: mockProvider, + }; + + (window.addEventListener as jest.Mock).mockImplementationOnce( + (eventName, callback) => { + if (eventName === EIP6963EventNames.Announce) { + callback({ + type: EIP6963EventNames.Announce, + detail: mockEventDetail, + }); + } + }, + ); + + const requestProviderPromise = await eip6963RequestProvider(); + + expect(requestProviderPromise).toBe(mockProvider); + }); +}); diff --git a/packages/sdk/src/utils/eip6963RequestProvider.ts b/packages/sdk/src/utils/eip6963RequestProvider.ts new file mode 100644 index 000000000..df87a9d8b --- /dev/null +++ b/packages/sdk/src/utils/eip6963RequestProvider.ts @@ -0,0 +1,57 @@ +import { METAMASK_EIP_6369_PROVIDER_INFO, UUID_V4_REGEX } from '../constants'; +import { SDKProvider } from '../provider/SDKProvider'; + +export enum EIP6963EventNames { + Announce = 'eip6963:announceProvider', + Request = 'eip6963:requestProvider', // eslint-disable-line @typescript-eslint/no-shadow +} + +export interface EIP6963ProviderInfo { + uuid: string; + name: string; + icon: string; + rdns: string; +} + +export interface EIP6963ProviderDetail { + info: EIP6963ProviderInfo; + provider: SDKProvider; +} + +export type EIP6963AnnounceProviderEvent = CustomEvent & { + type: EIP6963EventNames.Announce; + detail: EIP6963ProviderDetail; +}; + +export function eip6963RequestProvider(): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('eip6963RequestProvider timed out')); + }, 500); + + window.addEventListener( + EIP6963EventNames.Announce, + (eip6963AnnounceProviderEvent) => { + const event = + eip6963AnnounceProviderEvent as EIP6963AnnounceProviderEvent; + + const { detail: { info, provider } = {} } = event; + + const { name, rdns, uuid } = info ?? {}; + + const isValid = + UUID_V4_REGEX.test(uuid) && + name === METAMASK_EIP_6369_PROVIDER_INFO.NAME && + rdns === METAMASK_EIP_6369_PROVIDER_INFO.RDNS; + + if (isValid) { + clearTimeout(timeoutId); + + resolve(provider); + } + }, + ); + + window.dispatchEvent(new Event(EIP6963EventNames.Request)); + }); +} diff --git a/packages/sdk/src/utils/get-browser-extension.test.ts b/packages/sdk/src/utils/get-browser-extension.test.ts index a1ec025e7..154319e32 100644 --- a/packages/sdk/src/utils/get-browser-extension.test.ts +++ b/packages/sdk/src/utils/get-browser-extension.test.ts @@ -1,66 +1,97 @@ import { getBrowserExtension } from './get-browser-extension'; +import { eip6963RequestProvider } from './eip6963RequestProvider'; + +jest.mock('./eip6963RequestProvider'); describe('getBrowserExtension', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should throw an error if window is undefined', () => { + it('should throw an error if window is undefined', async () => { global.window = undefined as any; - expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow( + await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow( 'window not available', ); }); - it('should throw an error if ethereum is not found in window object', () => { + it('should return baseProvider if eip6963RequestProvider resolves successfully', async () => { + const mockProvider = { isMetaMask: true }; + global.window = { ethereum: {} } as any; + (eip6963RequestProvider as jest.Mock).mockResolvedValue(mockProvider); + + const res = await getBrowserExtension({ mustBeMetaMask: true }); + + expect(res).toStrictEqual(mockProvider); + }); + + it('should throw an error if eip6963RequestProvider rejects and ethereum is not found in window object', async () => { + (eip6963RequestProvider as jest.Mock).mockRejectedValue( + new Error('Provider request failed'), + ); global.window = {} as any; - expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow( + await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow( 'Ethereum not found in window object', ); }); - it('should throw an error if no suitable provider is found', () => { + it('should throw an error if no suitable provider is found', async () => { + (eip6963RequestProvider as jest.Mock).mockRejectedValue( + new Error('Provider request failed'), + ); global.window = { ethereum: { providers: [] } } as any; - expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow( + await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow( 'No suitable provider found', ); }); - it('should return MetaMask provider if mustBeMetaMask is true', () => { + it('should return MetaMask provider if mustBeMetaMask is true', async () => { const mockProvider = { isMetaMask: true }; + (eip6963RequestProvider as jest.Mock).mockRejectedValue( + new Error('Provider request failed'), + ); global.window = { ethereum: { providers: [mockProvider] } } as any; - expect(getBrowserExtension({ mustBeMetaMask: true })).toStrictEqual( - mockProvider, - ); + const res = await getBrowserExtension({ mustBeMetaMask: true }); + + expect(res).toStrictEqual(mockProvider); }); - it('should return the first provider if mustBeMetaMask is false', () => { + it('should return the first provider if mustBeMetaMask is false', async () => { const mockProvider = { isMetaMask: false }; + (eip6963RequestProvider as jest.Mock).mockRejectedValue( + new Error('Provider request failed'), + ); global.window = { ethereum: { providers: [mockProvider] } } as any; - expect(getBrowserExtension({ mustBeMetaMask: false })).toStrictEqual( - mockProvider, - ); + const res = await getBrowserExtension({ mustBeMetaMask: false }); + + expect(res).toStrictEqual(mockProvider); }); - it('should throw an error if mustBeMetaMask is true but MetaMask provider not found', () => { + it('should throw an error if mustBeMetaMask is true but MetaMask provider not found', async () => { + (eip6963RequestProvider as jest.Mock).mockRejectedValue( + new Error('Provider request failed'), + ); global.window = { ethereum: { isMetaMask: false } } as any; - expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow( + await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow( 'MetaMask provider not found in Ethereum', ); }); - it('should return ethereum object if mustBeMetaMask is false and ethereum object exists', () => { + it('should return ethereum object if mustBeMetaMask is false and ethereum object exists', async () => { const ethereumObj = { isMetaMask: true }; + (eip6963RequestProvider as jest.Mock).mockRejectedValue( + new Error('Provider request failed'), + ); global.window = { ethereum: ethereumObj } as any; - expect(getBrowserExtension({ mustBeMetaMask: false })).toStrictEqual( - ethereumObj, - ); + const res = await getBrowserExtension({ mustBeMetaMask: false }); + + expect(res).toStrictEqual(ethereumObj); }); }); diff --git a/packages/sdk/src/utils/get-browser-extension.ts b/packages/sdk/src/utils/get-browser-extension.ts index d5d66fee1..d67a6545c 100644 --- a/packages/sdk/src/utils/get-browser-extension.ts +++ b/packages/sdk/src/utils/get-browser-extension.ts @@ -1,36 +1,51 @@ -export function getBrowserExtension({ +import { SDKProvider } from '../provider/SDKProvider'; +import { eip6963RequestProvider } from './eip6963RequestProvider'; + +export async function getBrowserExtension({ mustBeMetaMask, }: { mustBeMetaMask: boolean; -}) { +}): Promise { if (typeof window === 'undefined') { throw new Error(`window not available`); } - const { ethereum } = window as { ethereum: any }; - - if (!ethereum) { - throw new Error('Ethereum not found in window object'); - } + try { + const baseProvider = await eip6963RequestProvider(); - // The `providers` field is populated when CoinBase Wallet extension is also installed - // The expected object is an array of providers, the MetaMask provider is inside - // See https://docs.cloud.coinbase.com/wallet-sdk/docs/injected-provider-guidance for - if (Array.isArray(ethereum.providers)) { - const provider = mustBeMetaMask - ? ethereum.providers.find((p: any) => p.isMetaMask) - : ethereum.providers[0]; + return baseProvider; + } catch (e) { + const { ethereum } = window as unknown as { + ethereum: + | (SDKProvider & { + isMetaMask: boolean; + }) + | { providers: any[] }; + }; - if (!provider) { - throw new Error('No suitable provider found'); + if (!ethereum) { + throw new Error('Ethereum not found in window object'); } - return provider; - } + // The `providers` field is populated when CoinBase Wallet extension is also installed + // The expected object is an array of providers, the MetaMask provider is inside + // See https://docs.cloud.coinbase.com/wallet-sdk/docs/injected-provider-guidance for + if ('providers' in ethereum) { + if (Array.isArray(ethereum.providers)) { + const provider = mustBeMetaMask + ? ethereum.providers.find((p: any) => p.isMetaMask) + : ethereum.providers[0]; - if (mustBeMetaMask && !ethereum.isMetaMask) { - throw new Error('MetaMask provider not found in Ethereum'); - } + if (!provider) { + throw new Error('No suitable provider found'); + } + + return provider as SDKProvider; + } + } else if (mustBeMetaMask && !ethereum.isMetaMask) { + throw new Error('MetaMask provider not found in Ethereum'); + } - return ethereum; + return ethereum as SDKProvider; + } } diff --git a/packages/sdk/src/utils/shouldInjectProvider.ts b/packages/sdk/src/utils/shouldInjectProvider.ts index 9a21718da..5c2aa6cd7 100644 --- a/packages/sdk/src/utils/shouldInjectProvider.ts +++ b/packages/sdk/src/utils/shouldInjectProvider.ts @@ -1,7 +1,7 @@ import { blockedDomainCheck } from './blockedDomainCheck'; +import { doctypeCheck } from './doctypeCheck'; import { documentElementCheck } from './documentElementCheck'; import { suffixCheck } from './suffixCheck'; -import { doctypeCheck } from './doctypeCheck'; export const shouldInjectProvider = () => { if (