diff --git a/packages/sdk-multichain/src/domain/platform/index.ts b/packages/sdk-multichain/src/domain/platform/index.ts index f484c1f34..9d25cb60b 100644 --- a/packages/sdk-multichain/src/domain/platform/index.ts +++ b/packages/sdk-multichain/src/domain/platform/index.ts @@ -80,9 +80,36 @@ export function isSecure() { return isReactNative() || platformType === PlatformType.MobileWeb; } -export function hasExtension() { - if (typeof window !== 'undefined') { - return window.ethereum?.isMetaMask ?? false; +// Immediately start MetaMask detection when module loads +const detectionPromise: Promise = (() => { + if (typeof window === 'undefined') { + return Promise.resolve(false); } - return false; + + return new Promise((resolve) => { + const providers: any[] = []; + + const handler = (event: any) => { + if (event?.detail?.info?.rdns) { + providers.push(event.detail); + } + }; + + window.addEventListener('eip6963:announceProvider', handler); + window.dispatchEvent(new Event('eip6963:requestProvider')); + + setTimeout(() => { + window.removeEventListener('eip6963:announceProvider', handler); + + const hasMetaMask = providers.some( + (p) => p?.info?.rdns === 'io.metamask', + ); + + resolve(hasMetaMask); + }, 300); // default timeout + }); +})(); + +export function hasExtension(): Promise { + return detectionPromise; } diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 8cb491d81..0ea7bb540 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -137,10 +137,11 @@ export class MultichainSDK extends MultichainCore { const { ui } = this.options; const { preferExtension = true, headless: _headless = false } = ui; const transportType = await this.storage.getTransport(); + const hasExtensionInstalled = await hasExtension(); if (transportType) { if (transportType === TransportType.Browser) { //Check if the user still have the extension or not return the transport - if (hasExtension() && preferExtension) { + if (hasExtensionInstalled && preferExtension) { const apiTransport = new DefaultTransport(); this.__transport = apiTransport; this.listener = apiTransport.onNotification(this.onTransportNotification.bind(this)); @@ -396,6 +397,7 @@ export class MultichainSDK extends MultichainCore { const isWeb = platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.DesktopWeb; const { preferExtension = true, preferDesktop = false, headless: _headless = false } = ui; const secure = isSecure(); + const hasExtensionInstalled = await hasExtension(); if (this.__transport?.isConnected() && !secure) { return this.handleConnection( @@ -409,7 +411,7 @@ export class MultichainSDK extends MultichainCore { ); } - if (isWeb && hasExtension() && preferExtension) { + if (isWeb && hasExtensionInstalled && preferExtension) { //If metamask extension is available, connect to it const defaultTransport = await this.setupDefaultTransport(); // Web transport has no initial payload @@ -420,7 +422,7 @@ export class MultichainSDK extends MultichainCore { await this.setupMWP(); // Determine preferred option for install modal - const isDesktopPreferred = hasExtension() ? preferDesktop : !preferExtension || preferDesktop; + const isDesktopPreferred = hasExtensionInstalled ? preferDesktop : !preferExtension || preferDesktop; if (secure && !isDesktopPreferred) { // Desktop is not preferred option, so we use deeplinks (mobile web) diff --git a/packages/sdk-multichain/src/multichain/transports/mwp/index.ts b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts index 92c491cc8..45ebbebdc 100644 --- a/packages/sdk-multichain/src/multichain/transports/mwp/index.ts +++ b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts @@ -1,10 +1,9 @@ -import { type CreateSessionParams, TransportTimeoutError, type TransportRequest, type TransportResponse } from '@metamask/multichain-api-client'; import type { Session, SessionRequest } from '@metamask/mobile-wallet-protocol-core'; import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; - -import { createLogger, type ExtendedTransport, type RPCAPI, type Scope, type SessionData, type StoreAdapter } from '../../../domain'; +import { type CreateSessionParams, type TransportRequest, type TransportResponse, TransportTimeoutError } from '@metamask/multichain-api-client'; import type { CaipAccountId } from '@metamask/utils'; +import { createLogger, type ExtendedTransport, type RPCAPI, type Scope, type SessionData, type StoreAdapter } from '../../../domain'; import { addValidAccounts, getOptionalScopes, getValidAccounts, isSameScopesAndAccounts } from '../../utils'; const DEFAULT_REQUEST_TIMEOUT = 60 * 1000; @@ -34,6 +33,7 @@ export class MWPTransport implements ExtendedTransport { private __pendingRequests = new Map(); private notificationCallbacks = new Set<(data: unknown) => void>(); private currentSessionRequest: SessionRequest | undefined; + private windowFocusHandler: (() => void) | undefined; get pendingRequests() { return this.__pendingRequests; @@ -54,7 +54,8 @@ export class MWPTransport implements ExtendedTransport { ) { this.dappClient.on('message', this.handleMessage.bind(this)); if (typeof window !== 'undefined' && typeof window.addEventListener !== 'undefined') { - window.addEventListener('focus', this.onWindowFocus.bind(this)); + this.windowFocusHandler = this.onWindowFocus.bind(this); + window.addEventListener('focus', this.windowFocusHandler); } } @@ -121,9 +122,9 @@ export class MWPTransport implements ExtendedTransport { if (walletSession && options) { const currentScopes = Object.keys(walletSession?.sessionScopes ?? {}) as Scope[]; const proposedScopes = options?.scopes ?? []; - const proposedCaipAccountIds = options?.caipAccountIds ?? []; - const hasSameScopesAndAccounts = isSameScopesAndAccounts(currentScopes, proposedScopes, walletSession, proposedCaipAccountIds); - if (!hasSameScopesAndAccounts) { + const proposedCaipAccountIds = options?.caipAccountIds ?? []; + const hasSameScopesAndAccounts = isSameScopesAndAccounts(currentScopes, proposedScopes, walletSession, proposedCaipAccountIds); + if (!hasSameScopesAndAccounts) { const optionalScopes = addValidAccounts(getOptionalScopes(options?.scopes ?? []), getValidAccounts(options?.caipAccountIds ?? [])); const sessionRequest: CreateSessionParams = { optionalScopes }; const response = await this.request({ method: 'wallet_createSession', params: sessionRequest }); @@ -169,7 +170,7 @@ export class MWPTransport implements ExtendedTransport { logger('active session found', activeSession); session = activeSession; } - } catch {} + } catch { } let timeout: NodeJS.Timeout; const connectionPromise = new Promise((resolve, reject) => { @@ -229,6 +230,11 @@ export class MWPTransport implements ExtendedTransport { * Disconnects from the Mobile Wallet Protocol */ async disconnect(): Promise { + // Clean up window focus event listener + if (typeof window !== 'undefined' && typeof window.removeEventListener !== 'undefined' && this.windowFocusHandler) { + window.removeEventListener('focus', this.windowFocusHandler); + this.windowFocusHandler = undefined; + } return this.dappClient.disconnect(); } diff --git a/packages/sdk-multichain/src/utis/index.test.ts b/packages/sdk-multichain/src/utis/index.test.ts deleted file mode 100644 index 4a49c0522..000000000 --- a/packages/sdk-multichain/src/utis/index.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ -/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ -import * as t from 'vitest'; -import { vi } from 'vitest'; -import packageJson from '../../package.json'; -import type { MultichainOptions } from '../domain/multichain'; -import { getPlatformType, PlatformType } from '../domain/platform'; -import * as utils from '.'; - -vi.mock('../domain/platform', async () => { - const actual = (await vi.importActual('../domain/platform')) as any; - return { - ...actual, - getPlatformType: t.vi.fn(), - }; -}); - -t.describe('Utils', () => { - let options: MultichainOptions; - - t.beforeEach(() => { - t.vi.clearAllMocks(); - options = { - dapp: { - name: 'test', - url: 'test', - }, - api: { - infuraAPIKey: 'testKey', - }, - } as MultichainOptions; - }); - - t.describe('getDappId', () => { - const mockHostname = 'mockdapp.com'; - const mockDappName = 'Mock DApp Name'; - const mockDappUrl = 'http://mockdapp.com'; - const originalWindow = global.window; - - t.afterEach(() => { - global.window = originalWindow; - }); - - t.it('should return window.location.hostname if window and window.location are defined', () => { - global.window = { - location: { - hostname: mockHostname, - }, - } as any; - t.expect(utils.getDappId()).toBe(mockHostname); - }); - - t.it('should return dappMetadata.name if window is undefined and name is available', () => { - global.window = undefined as any; - const dappSettings = { name: mockDappName, url: mockDappUrl }; - t.expect(utils.getDappId(dappSettings)).toBe(mockDappName); - }); - - t.it('should return dappMetadata.url if window is undefined and name is not available but url is', () => { - global.window = undefined as any; - const dappSettings = { url: mockDappUrl }; - t.expect(utils.getDappId(dappSettings)).toBe(mockDappUrl); - }); - - t.it('should return "N/A" if window is undefined and neither name nor url is available', () => { - global.window = undefined as any; - const dappSettings = {}; - t.expect(utils.getDappId(dappSettings)).toBe('N/A'); - }); - }); - - t.describe('getSDKVersion', () => { - t.it('should get SDK version', () => { - t.expect(utils.getVersion()).toBe(packageJson.version); - }); - }); - - t.describe('extractFavicon', () => { - t.it('should return undefined if document is undefined', () => { - global.document = undefined as any; - - t.expect(utils.extractFavicon()).toBeUndefined(); - }); - - t.it('should return favicon href if rel is icon', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => (attr === 'rel' ? 'icon' : '/favicon.ico'), - }, - ]), - } as any; - - t.expect(utils.extractFavicon()).toBe('/favicon.ico'); - }); - - t.it('should return favicon href if rel is shortcut icon', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => (attr === 'rel' ? 'shortcut icon' : '/favicon.ico'), - }, - ]), - } as any; - - t.expect(utils.extractFavicon()).toBe('/favicon.ico'); - }); - - t.it('should return undefined if no favicon is found', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([]), - } as any; - - t.expect(utils.extractFavicon()).toBeUndefined(); - }); - - t.it('should return undefined if rel attribute is different', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => (attr === 'rel' ? 'something else' : '/favicon.ico'), - }, - ]), - } as any; - - t.expect(utils.extractFavicon()).toBeUndefined(); - }); - }); - - t.describe('setupInfuraProvider', () => { - t.it('should not set up infura provider if infuraAPIKey is not provided', async () => { - options.api!.infuraAPIKey = undefined; - await utils.setupInfuraProvider(options); - t.expect(options.api?.readonlyRPCMap).toBeUndefined(); - }); - - t.it('should set up infura provider with infuraAPIKey', async () => { - await utils.setupInfuraProvider(options); - t.expect(options.api?.readonlyRPCMap?.['eip155:1']).toBe(`https://mainnet.infura.io/v3/testKey`); - }); - - t.it('Should allow customizing the readonlyRPCMap + merge with defaults', async () => { - const customChainId = 'eip155:12345'; - const customEndpoint = 'https://mainnet.infura.io/12345'; - options.api!.readonlyRPCMap = { - [customChainId]: customEndpoint, - }; - await utils.setupInfuraProvider(options); - t.expect(options.api?.readonlyRPCMap?.['eip155:1']).toBe(`https://mainnet.infura.io/v3/testKey`); - t.expect(options.api?.readonlyRPCMap?.[customChainId]).toBe(customEndpoint); - }); - }); - - t.describe('setupDappMetadata', () => { - t.beforeEach(() => { - // Mock the document object to avoid undefined errors - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([]), - } as any; - - t.vi.spyOn(utils, 'extractFavicon').mockReturnValue('xd'); - }); - - t.afterEach(() => { - t.vi.restoreAllMocks(); - }); - - t.it('should attach dappMetadata to the instance if valid', async () => { - (options.dapp as any).iconUrl = 'https://example.com/favicon.ico'; - options.dapp.url = 'https://example.com'; - const originalDappOptions = { - ...options.dapp, - }; - await utils.setupDappMetadata(options); - t.expect(options.dapp).toStrictEqual(originalDappOptions); - }); - - t.it('should set iconUrl to undenied if it does not start with http:// or https:// and favicon is undefined', async () => { - (options.dapp as any).iconUrl = 'ftp://example.com/favicon.ico'; - options.dapp.url = 'https://example.com'; - const consoleWarnSpy = t.vi.spyOn(console, 'warn'); - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).iconUrl).toBeUndefined(); - t.expect(consoleWarnSpy).toHaveBeenCalledWith('Invalid dappMetadata.iconUrl: URL must start with http:// or https://'); - }); - - t.it('should set url to undenied if it does not start with http:// or https:// and favicon is undefined', async () => { - options.dapp.url = 'wrong'; - const consoleWarnSpy = t.vi.spyOn(console, 'warn'); - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).iconUrl).toBeUndefined(); - t.expect(consoleWarnSpy).toHaveBeenCalledWith('Invalid dappMetadata.url: URL must start with http:// or https://'); - }); - - t.it('should prove that dapp is mandatory is platform is not browser', async () => { - (options.dapp as any) = undefined; - await t.expect(() => utils.setupDappMetadata(options)).toThrow('You must provide dapp url'); - }); - - t.it('should prove that dapp is optional is platform is browser', async () => { - (getPlatformType as any).mockReturnValue(PlatformType.DesktopWeb); - t.vi.stubGlobal('window', { - location: { - protocol: 'https:', - host: 'example.com', - }, - }); - (options.dapp as any) = undefined; - utils.setupDappMetadata(options); - t.expect(options.dapp.url).toBe('https://example.com'); - }); - - t.it('should set base64Icon to undefined if its length exceeds 163400 characters', async () => { - const longString = new Array(163401).fill('a').join(''); - const consoleWarnSpy = t.vi.spyOn(console, 'warn'); - - options.dapp = { - iconUrl: 'https://example.com/favicon.ico', - url: 'https://example.com', - base64Icon: longString, - }; - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).base64Icon).toBeUndefined(); - t.expect(consoleWarnSpy).toHaveBeenCalledWith('Invalid dappMetadata.base64Icon: Base64-encoded icon string length must be less than 163400 characters'); - }); - - t.it('should set iconUrl to the extracted favicon if iconUrl and base64Icon are not provided', async () => { - options.dapp = { - url: 'https://example.com', - }; - - global.window = { - location: { - protocol: 'https:', - host: 'example.com', - }, - } as any; - - // Mock document.getElementsByTagName to return a link element with favicon - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => { - if (attr === 'rel') return 'icon'; - if (attr === 'href') return '/favicon.ico'; - return null; - }, - }, - ]), - } as any; - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).iconUrl).toBe('https://example.com/favicon.ico'); - }); - }); - - t.describe('isMetaMaskInstalled', () => { - t.it('should return true if MetaMask is installed', () => { - t.vi.stubGlobal('window', { - ethereum: { - isMetaMask: true, - }, - }); - t.expect(utils.isMetaMaskInstalled()).toBe(true); - }); - - t.it('should return false if MetaMask is not installed', () => { - t.vi.stubGlobal('window', undefined); - t.expect(utils.isMetaMaskInstalled()).toBe(false); - }); - }); -}); diff --git a/packages/sdk-multichain/src/utis/index.ts b/packages/sdk-multichain/src/utis/index.ts deleted file mode 100644 index e8caaa9d2..000000000 --- a/packages/sdk-multichain/src/utis/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import * as uuid from 'uuid'; -import packageJson from '../../package.json'; -import { type DappSettings, getInfuraRpcUrls, type MultichainOptions } from '../domain/multichain'; -import { getPlatformType, PlatformType } from '../domain/platform'; -import type { StoreClient } from '../domain/store/client'; - -export function getVersion() { - return packageJson.version; -} - -export function getDappId(dapp?: DappSettings) { - if (typeof window === 'undefined' || typeof window.location === 'undefined') { - return dapp?.name ?? dapp?.url ?? 'N/A'; - } - - return window.location.hostname; -} - -export async function getAnonId(storage: StoreClient) { - const anonId = await storage.getAnonId(); - if (anonId) { - return anonId; - } - const newAnonId = uuid.v4(); - await storage.setAnonId(newAnonId); - return newAnonId; -} - -export const extractFavicon = () => { - if (typeof document === 'undefined') { - return undefined; - } - - let favicon: string | undefined; - const nodeList = document.getElementsByTagName('link'); - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < nodeList.length; i++) { - if (nodeList[i].getAttribute('rel') === 'icon' || nodeList[i].getAttribute('rel') === 'shortcut icon') { - favicon = nodeList[i].getAttribute('href') ?? undefined; - } - } - return favicon; -}; - -export function setupInfuraProvider(options: MultichainOptions): MultichainOptions { - const infuraAPIKey = options.api?.infuraAPIKey; - if (!infuraAPIKey) { - return options; - } - const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); - if (options.api?.readonlyRPCMap) { - options.api.readonlyRPCMap = { - ...options.api.readonlyRPCMap, - ...urlsWithToken, - }; - } else if (options.api) { - options.api.readonlyRPCMap = urlsWithToken; - } - return options; -} - -export function setupDappMetadata(options: MultichainOptions): MultichainOptions { - const platform = getPlatformType(); - const isBrowser = platform === PlatformType.DesktopWeb || platform === PlatformType.MobileWeb || platform === PlatformType.MetaMaskMobileWebview; - - if (!options.dapp?.url) { - // Automatically set dappMetadata on web env if not defined - if (isBrowser) { - options.dapp = { - ...options.dapp, - url: `${window.location.protocol}//${window.location.host}`, - }; - } else { - throw new Error('You must provide dapp url'); - } - } - const BASE_64_ICON_MAX_LENGTH = 163400; - // Check if iconUrl and url are valid - const urlPattern = /^(http|https):\/\/[^\s]*$/; // Regular expression for URLs starting with http:// or https:// - if (options.dapp) { - if ('iconUrl' in options.dapp) { - if (options.dapp.iconUrl && !urlPattern.test(options.dapp.iconUrl)) { - console.warn('Invalid dappMetadata.iconUrl: URL must start with http:// or https://'); - options.dapp.iconUrl = undefined; - } - } - // This check ensures that the base64Icon string in the dappMetadata does not exceed 163,400 characters. - // The character limit is important because a longer base64-encoded string causes the connection to the mobile app to fail. - // Keeping the base64Icon string length below this threshold ensures reliable communication and functionality. - if ('base64Icon' in options.dapp) { - if (options.dapp.base64Icon && options.dapp.base64Icon.length > BASE_64_ICON_MAX_LENGTH) { - console.warn('Invalid dappMetadata.base64Icon: Base64-encoded icon string length must be less than 163400 characters'); - - options.dapp.base64Icon = undefined; - } - } - if (options.dapp.url && !urlPattern.test(options.dapp.url)) { - console.warn('Invalid dappMetadata.url: URL must start with http:// or https://'); - } - const favicon = extractFavicon(); - if (favicon && !('iconUrl' in options.dapp) && !('base64Icon' in options.dapp)) { - const faviconUrl = `${window.location.protocol}//${window.location.host}${favicon}`; - // @ts-ignore - options.dapp.iconUrl = faviconUrl; - } - } - return options; -} - -/** - * Check if MetaMask extension is installed - */ -export function isMetaMaskInstalled(): boolean { - if (typeof window === 'undefined') { - return false; - } - return Boolean(window.ethereum?.isMetaMask); -} diff --git a/packages/sdk-multichain/tests/mocks/index.ts b/packages/sdk-multichain/tests/mocks/index.ts index eeef8a7a7..c5260e4d0 100644 --- a/packages/sdk-multichain/tests/mocks/index.ts +++ b/packages/sdk-multichain/tests/mocks/index.ts @@ -5,3 +5,4 @@ import './dappClient'; import './uiPackage'; import './ws'; import './mwp'; +import './platform'; diff --git a/packages/sdk-multichain/tests/mocks/platform.ts b/packages/sdk-multichain/tests/mocks/platform.ts new file mode 100644 index 000000000..c9aa16625 --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/platform.ts @@ -0,0 +1,19 @@ +/** + * This file mocks the platform detection module + * We mock hasExtension to return based on window.ethereum.isMetaMask + */ +import * as t from 'vitest'; + +t.vi.mock('../../src/domain/platform', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasExtension: t.vi.fn(async () => { + if (typeof window === 'undefined') { + return false; + } + return Boolean(window.ethereum?.isMetaMask); + }) + }; +}); +