diff --git a/biome.json b/biome.json index 7110e6e3a..808650c57 100644 --- a/biome.json +++ b/biome.json @@ -1,36 +1,36 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "includes": ["packages/sdk-multichain/src/**", "!**/node_modules/**", "!**/dist/**"], - "ignoreUnknown": false - }, - "formatter": { - "enabled": true, - "indentStyle": "tab", - "lineWidth": 180 - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single" - } - }, - "assist": { - "enabled": true, - "actions": { - "source": { - "organizeImports": "on" - } - } - } + "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "includes": ["packages/sdk-multichain/src/**", "!**/node_modules/**", "!**/dist/**"], + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 180 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } } diff --git a/packages/sdk-multichain/package.json b/packages/sdk-multichain/package.json index b8a38e86c..f65851234 100644 --- a/packages/sdk-multichain/package.json +++ b/packages/sdk-multichain/package.json @@ -49,12 +49,14 @@ "license": "MIT", "dependencies": { "@metamask/multichain-api-client": "^0.6.4", + "@metamask/onboarding": "^1.0.1", "@metamask/sdk-analytics": "workspace:^", + "@metamask/sdk-install-modal-web": "workspace:^", "@metamask/utils": "^11.4.0", "@paulmillr/qr": "^0.2.1", "bowser": "^2.11.0", "cross-fetch": "^4.1.0", - "eventemitter2": "^6.4.9", + "eventemitter3": "^5.0.1", "uuid": "^11.1.0" } } diff --git a/packages/sdk-multichain/src/connect.test.ts b/packages/sdk-multichain/src/connect.test.ts new file mode 100644 index 000000000..c7efd6860 --- /dev/null +++ b/packages/sdk-multichain/src/connect.test.ts @@ -0,0 +1,285 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import fs from 'node:fs'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { JSDOM as Page } from 'jsdom'; +import * as t from 'vitest'; +import type { MultiChainFNOptions, MultichainCore, Scope } from './domain'; +// Carefull, order of import matters to keep mocks working +import { createTest, type MockedData, mockSessionData, type TestSuiteOptions } from './fixtures.test'; +import { createMetamaskSDK as createMetamaskSDKWeb } from './index.browser'; +import { createMetamaskSDK as createMetamaskSDKRN } from './index.native'; +import { createMetamaskSDK as createMetamaskSDKNode } from './index.node'; +import { Store } from './store'; +import * as nodeStorage from './store/adapters/node'; +import * as rnStorage from './store/adapters/rn'; +import * as webStorage from './store/adapters/web'; + +function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { + const { beforeEach, afterEach } = options; + const originalSdkOptions = sdkOptions; + let sdk: MultichainCore; + + t.describe(`${platform} tests`, () => { + let mockedData: MockedData; + let testOptions: T; + const transportString = platform === 'web' ? 'browser' : 'mwp'; + + t.beforeEach(async () => { + mockedData = await beforeEach(); + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + // Set the transport type as a string in storage (this is how it's stored) + testOptions = { + ...originalSdkOptions, + analytics: { + ...originalSdkOptions.analytics, + enabled: platform !== 'node', + integrationType: 'test', + }, + storage: new Store(mockedData.nativeStorageStub), + }; + }); + + t.afterEach(async () => { + await afterEach(mockedData); + }); + + t.it(`${platform} should connect transport and create session when not connected`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockMultichainClient.getSession.mockResolvedValue(undefined); + mockMultichainClient.createSession.mockResolvedValue(mockSessionData); + mockedData.mockTransport.isConnected.mockReturnValue(false); + + // Create a new SDK instance with the mock configured correctly + const sdk = await createSDK(testOptions); + + t.expect(sdk.state).toBe('loaded'); + t.expect(sdk.provider).toBeDefined(); + t.expect(sdk.transport).toBeDefined(); + t.expect(sdk.storage).toBeDefined(); + t.expect(mockedData.mockTransport.connect).toHaveBeenCalled(); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockedData.emitSpy).not.toHaveBeenCalled(); + + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + + await sdk.connect(scopes, caipAccountIds); + + t.expect(mockedData.mockTransport.connect).toHaveBeenCalled(); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + }, + }, + }); + }); + + t.it(`${platform} should skip transport connection when already connected`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + + mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + + sdk = await createSDK(testOptions); + t.expect(sdk.provider).toBeDefined(); + t.expect(sdk.transport).toBeDefined(); + t.expect(sdk.storage).toBeDefined(); + t.expect(mockedData.mockTransport.connect).toHaveBeenCalled(); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockedData.emitSpy).toHaveBeenCalledWith('sessionChanged', mockSessionData); + + mockedData.mockTransport.connect.mockReset(); + + await sdk.connect(scopes, caipAccountIds); + t.expect(mockedData.mockTransport.connect).not.toHaveBeenCalled(); + }); + + t.it(`${platform} should handle invalid CAIP account IDs gracefully`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockedData.mockTransport.isConnected.mockReturnValue(false); + mockMultichainClient.getSession.mockResolvedValue(undefined); + + // Mock console.error to capture invalid account ID errors + const consoleErrorSpy = t.vi.spyOn(console, 'error').mockImplementation(() => {}); + + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['invalid-account-id', 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + sdk = await createSDK(testOptions); + await sdk.connect(scopes, caipAccountIds); + + t.expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid CAIP account ID: "invalid-account-id"', t.expect.any(Error)); + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + }, + }, + }); + consoleErrorSpy.mockRestore(); + }); + + t.it(`${platform} should handle transport connection errors`, async () => { + const connectionError = new Error('Failed to connect transport'); + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + mockedData.mockTransport.isConnected.mockReturnValue(false); + mockedData.mockTransport.connect.mockRejectedValue(connectionError); + + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + sdk = await createSDK(testOptions); + await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to connect transport'); + }); + + t.it(`${platform} should handle session creation errors`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockedData.mockTransport.isConnected.mockReturnValue(true); + mockMultichainClient.getSession.mockResolvedValue(undefined); + + const sessionError = new Error('Failed to create session'); + mockMultichainClient.createSession.mockRejectedValue(sessionError); + + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + sdk = await createSDK(testOptions); + await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to create session'); + }); + + t.it(`${platform} should handle session revocation errors`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockedData.mockTransport.isConnected.mockReturnValue(true); + + const existingSessionData = { + ...mockSessionData, + sessionScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction'], + notifications: ['accountsChanged'], + accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + }, + }, + }; + + mockMultichainClient.getSession.mockResolvedValue(existingSessionData); + + const revocationError = new Error('Failed to revoke session'); + mockMultichainClient.revokeSession.mockRejectedValue(revocationError); + + const scopes = ['eip155:137'] as Scope[]; // Same scope as existing session to trigger revocation + const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; + sdk = await createSDK(testOptions); + await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to revoke session'); + }); + + t.it(`${platform} should disconnect transport successfully`, async () => { + mockedData.mockTransport.isConnected.mockReturnValue(true); + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + + sdk = await createSDK(testOptions); + await sdk.disconnect(); + t.expect(mockedData.mockTransport.disconnect).toHaveBeenCalled(); + }); + + t.it(`${platform} should handle disconnect errors`, async () => { + mockedData.mockTransport.isConnected.mockReturnValue(true); + const disconnectError = new Error('Failed to disconnect transport'); + mockedData.mockTransport.disconnect.mockRejectedValue(disconnectError); + sdk = await createSDK(testOptions); + await t.expect(sdk.disconnect()).rejects.toThrow('Failed to disconnect transport'); + }); + }); +} + +const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; + +const baseTestOptions = { options: { dapp: exampleDapp }, tests: testSuite }; + +createTest({ + ...baseTestOptions, + platform: 'node', + createSDK: createMetamaskSDKNode, + setupMocks: (nativeStorageStub) => { + const memfs = new Map(); + t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString())); + t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data)); + t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString())); + t.vi.spyOn(nodeStorage, 'StoreAdapterNode').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'rn', + createSDK: createMetamaskSDKRN, + setupMocks: (nativeStorageStub) => { + t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key)); + t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)); + t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.deleteItem(key)); + t.vi.spyOn(rnStorage, 'StoreAdapterRN').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'web', + createSDK: createMetamaskSDKWeb, + setupMocks: (nativeStorageStub) => { + const dom = new Page('

Hello world

', { + url: exampleDapp.url, + }); + const globalStub = { + ...dom.window, + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + localStorage: nativeStorageStub, + ethereum: { + isMetaMask: true, + }, + }; + t.vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', + language: 'en-US', + }); + t.vi.stubGlobal('window', globalStub); + t.vi.stubGlobal('location', dom.window.location); + t.vi.stubGlobal('document', dom.window.document); + t.vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + t.vi.stubGlobal('requestAnimationFrame', t.vi.fn()); + t.vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); diff --git a/packages/sdk-multichain/src/domain/events/index.ts b/packages/sdk-multichain/src/domain/events/index.ts index 05ce31c1a..c57da51d4 100644 --- a/packages/sdk-multichain/src/domain/events/index.ts +++ b/packages/sdk-multichain/src/domain/events/index.ts @@ -1,6 +1,4 @@ -import { EventEmitter2 } from 'eventemitter2'; - -import type { EventTypes } from './types'; +import { EventEmitter as EventEmitter3 } from 'eventemitter3'; /** * A type-safe event emitter that provides a strongly-typed wrapper around EventEmitter2. @@ -11,8 +9,8 @@ import type { EventTypes } from './types'; * @template TEvents - A record type mapping event names to their argument types. * Each key represents an event name, and the value is a tuple of argument types. */ -export class EventEmitter = EventTypes> { - readonly #emitter = new EventEmitter2(); +export class EventEmitter> { + readonly #emitter = new EventEmitter3(); /** * Emits an event with the specified name and arguments. @@ -34,19 +32,9 @@ export class EventEmitter = EventTypes */ on(eventName: TEventName, handler: (...eventArg: TEvents[TEventName]) => void) { this.#emitter.on(eventName, handler); - } - - /** - * Sets the maximum number of listeners that can be registered for any single event. - * - * This is useful for preventing memory leaks when many listeners are registered. - * By default, EventEmitter2 will warn if more than 10 listeners are registered - * for a single event. - * - * @param maxListeners - The maximum number of listeners per event (0 means unlimited) - */ - setMaxListeners(maxListeners: number) { - this.#emitter.setMaxListeners(maxListeners); + return () => { + this.off(eventName, handler); + }; } /** diff --git a/packages/sdk-multichain/src/domain/events/types/extension.ts b/packages/sdk-multichain/src/domain/events/types/extension.ts deleted file mode 100644 index 1df0c99aa..000000000 --- a/packages/sdk-multichain/src/domain/events/types/extension.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Extension native Events - */ -export type ExtensionEvents = { - chainChanged: [evt: unknown]; -}; diff --git a/packages/sdk-multichain/src/domain/events/types/index.ts b/packages/sdk-multichain/src/domain/events/types/index.ts index 1ff8a790d..9e2f17c37 100644 --- a/packages/sdk-multichain/src/domain/events/types/index.ts +++ b/packages/sdk-multichain/src/domain/events/types/index.ts @@ -1,5 +1,8 @@ -import type { ExtensionEvents } from './extension'; -import type { SDKEvents } from './sdk'; +import type { SessionData } from '@metamask/multichain-api-client'; -export type EventTypes = SDKEvents | ExtensionEvents; -export type { SDKEvents, ExtensionEvents }; +export type SDKEvents = { + display_uri: [evt: string]; + sessionChanged: [evt: SessionData | undefined]; +}; + +export type EventTypes = SDKEvents; diff --git a/packages/sdk-multichain/src/domain/events/types/sdk.ts b/packages/sdk-multichain/src/domain/events/types/sdk.ts deleted file mode 100644 index 96422149a..000000000 --- a/packages/sdk-multichain/src/domain/events/types/sdk.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type SDKEvents = { - display_uri: [evt: string]; -}; diff --git a/packages/sdk-multichain/src/domain/index.test.ts b/packages/sdk-multichain/src/domain/index.test.ts index 1bd250f00..b8050a671 100644 --- a/packages/sdk-multichain/src/domain/index.test.ts +++ b/packages/sdk-multichain/src/domain/index.test.ts @@ -1,7 +1,9 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ import Bowser from 'bowser'; import * as t from 'vitest'; -import { createLogger, EventEmitter, type ExtensionEvents, enableDebug, getPlatformType, isEnabled, PlatformType, type SDKEvents } from './'; +import { createLogger, EventEmitter, enableDebug, getPlatformType, isEnabled, PlatformType, type SDKEvents } from './'; const parseMock = t.vi.fn(); // Mock Bowser at the top level @@ -12,14 +14,12 @@ t.vi.mock('bowser', () => ({ })); t.describe('Platform Detection', () => { - let mockBowser: { parse: t.MockedFunction }; - t.beforeEach(() => { // Reset mocks t.vi.clearAllMocks(); t.vi.unstubAllGlobals(); // Get the mocked instance - Bowser is a default export - mockBowser = t.vi.mocked(Bowser); + t.vi.mocked(Bowser); }); t.describe('getPlatformType', () => { @@ -414,91 +414,6 @@ t.describe('EventEmitter', () => { eventEmitter.off('display_uri', handler); }).not.toThrow(); }); - t.it('should not leak memory when handlers are removed', () => { - const handlers: Array<() => void> = []; - - // Set a higher limit to avoid warnings - eventEmitter.setMaxListeners(200); - - // Add many handlers - for (let i = 0; i < 100; i++) { - const handler = t.vi.fn(); - handlers.push(handler); - eventEmitter.on('display_uri', handler); - } - - // Remove all handlers - handlers.forEach((handler) => { - eventEmitter.off('display_uri', handler); - }); - - // Emit should not call any handlers - eventEmitter.emit('display_uri', 'should not be handled'); - - handlers.forEach((handler) => { - t.expect(handler).not.toHaveBeenCalled(); - }); - }); - }); - - t.describe('ExtensionEvents', () => { - let eventEmitter: EventEmitter; - t.beforeEach(() => { - eventEmitter = new EventEmitter(); - }); - - t.it('should emit chainChanged event with unknown argument', () => { - const handler = t.vi.fn(); - eventEmitter.on('chainChanged', handler); - - const chainData = { chainId: '0x1', networkName: 'mainnet' }; - eventEmitter.emit('chainChanged', chainData); - - t.expect(handler).toHaveBeenCalledWith(chainData); - t.expect(handler).toHaveBeenCalledTimes(1); - }); - - t.it('should emit chainChanged event with various data types', () => { - const handler = t.vi.fn(); - eventEmitter.on('chainChanged', handler); - - // Test with string - eventEmitter.emit('chainChanged', 'chain-id'); - t.expect(handler).toHaveBeenCalledWith('chain-id'); - - // Test with number - eventEmitter.emit('chainChanged', 1); - t.expect(handler).toHaveBeenCalledWith(1); - - // Test with null - eventEmitter.emit('chainChanged', null); - t.expect(handler).toHaveBeenCalledWith(null); - - t.expect(handler).toHaveBeenCalledTimes(3); - }); - - t.it('should register handlers for chainChanged event', () => { - const handler = t.vi.fn(); - - eventEmitter.on('chainChanged', handler); - eventEmitter.emit('chainChanged', { chainId: '0x89' }); - - t.expect(handler).toHaveBeenCalledWith({ chainId: '0x89' }); - }); - - t.it('should remove specific handlers for chainChanged event', () => { - const handler1 = t.vi.fn(); - const handler2 = t.vi.fn(); - - eventEmitter.on('chainChanged', handler1); - eventEmitter.on('chainChanged', handler2); - - eventEmitter.off('chainChanged', handler1); - eventEmitter.emit('chainChanged', { chainId: '0x1' }); - - t.expect(handler1).not.toHaveBeenCalled(); - t.expect(handler2).toHaveBeenCalledWith({ chainId: '0x1' }); - }); }); }); }); diff --git a/packages/sdk-multichain/src/domain/index.ts b/packages/sdk-multichain/src/domain/index.ts index ea8d037fc..0c01a56e8 100644 --- a/packages/sdk-multichain/src/domain/index.ts +++ b/packages/sdk-multichain/src/domain/index.ts @@ -4,3 +4,5 @@ export * from './logger'; export * from './multichain'; export * from './platform'; export * from './store'; +export * from './ui'; +export * from './utils'; diff --git a/packages/sdk-multichain/src/domain/logger/index.ts b/packages/sdk-multichain/src/domain/logger/index.ts index a400ecd40..2ba0a9821 100644 --- a/packages/sdk-multichain/src/domain/logger/index.ts +++ b/packages/sdk-multichain/src/domain/logger/index.ts @@ -6,7 +6,7 @@ import type { StoreClient } from '../store/client'; * Supported debug namespace types for the MetaMask SDK logger. * These namespaces help categorize and filter debug output. */ -export type LoggerNameSpaces = 'metamask-sdk' | 'metamask-sdk:core' | 'metamask-sdk:provider'; +export type LoggerNameSpaces = 'metamask-sdk' | 'metamask-sdk:core' | 'metamask-sdk:provider' | 'metamask-sdk:ui'; /** * Creates a debug logger instance with the specified namespace and color. @@ -64,7 +64,7 @@ function isNamespaceEnabled(debugValue: string, namespace: LoggerNameSpaces) { * @returns Promise that resolves to true if debug logging is enabled, false otherwise */ export const isEnabled = async (namespace: LoggerNameSpaces, storage: StoreClient) => { - if (process?.env?.DEBUG) { + if ('process' in globalThis && process?.env?.DEBUG) { const { DEBUG } = process.env; return isNamespaceEnabled(DEBUG, namespace); } diff --git a/packages/sdk-multichain/src/domain/multichain/index.ts b/packages/sdk-multichain/src/domain/multichain/index.ts index cb54550d0..230ad4eb9 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -1,50 +1,17 @@ -import type { SessionData } from '@metamask/multichain-api-client'; +import type { MultichainApiClient, Transport } from '@metamask/multichain-api-client'; import type { CaipAccountId, Json } from '@metamask/utils'; - +import { EventEmitter, type SDKEvents } from '../events'; import type { StoreClient } from '../store/client'; -import type { InvokeMethodOptions, NotificationCallback, RPC_URLS_MAP, Scope } from './api/types'; +import type { InvokeMethodOptions, RPCAPI, Scope } from './api/types'; +import type { MultichainOptions } from './types'; -/** - * Configuration settings for the dapp using the SDK. - * - * This type allows for two variants of dapp configuration: - * - Using a regular icon URL - * - Using a base64-encoded icon - */ -export type DappSettings = { - name?: string; - url?: string; -} & ({ iconUrl?: string } | { base64Icon?: string }); +export type SDKState = 'pending' | 'loaded' | 'disconnected' | 'connected'; -/** - * Constructor options for creating a Multichain SDK instance. - * - * This type defines all the configuration options available when - * initializing the SDK, including dapp settings, API configuration, - * analytics, storage, UI preferences, and transport options. - */ -export type MultichainSDKConstructor = { - /** Dapp identification and branding settings */ - dapp: DappSettings; - /** Optional API configuration for external services */ - api?: { - /** The Infura API key to use for RPC requests */ - infuraAPIKey?: string; - /** A map of RPC URLs to use for read-only requests */ - readonlyRPCMap?: RPC_URLS_MAP; - }; - /** Analytics configuration */ - analytics: { enabled: false } | { enabled: true; integrationType: string }; - /** Storage client for persisting SDK data */ - storage: StoreClient; - /** UI configuration options */ - ui: { headless: boolean }; - /** Optional transport configuration */ - transport?: { - /** Extension ID for browser extension transport */ - extensionId?: string; - }; -}; +export enum TransportType { + Browser = 'browser', + MPW = 'mwp', + UNKNOWN = 'unknown', +} /** * Abstract base class for the Multichain SDK implementation. @@ -52,39 +19,25 @@ export type MultichainSDKConstructor = { * This class defines the core interface that all Multichain SDK implementations * must provide, including session management, connection handling, and method invocation. */ -/* c8 ignore start */ -export abstract class MultichainSDKBase { - public abstract isInitialized: boolean; - public abstract session: SessionData | undefined; +export abstract class MultichainCore extends EventEmitter { + abstract storage: StoreClient; + abstract state: SDKState; + abstract provider: MultichainApiClient; + abstract transport: Transport; + abstract init(): Promise; /** * Establishes a connection to the multichain provider, or re-use existing session * * @returns Promise that resolves to the session data */ - abstract connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise; - + abstract connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise; /** * Disconnects from the multichain provider. * * @returns Promise that resolves when disconnection is complete */ abstract disconnect(): Promise; - /** - * Revokes the current session. - * - * @returns Promise that resolves when the session has been revoked - */ - abstract revokeSession(): Promise; - - /** - * Registers a listener for incoming notifications. - * - * @param listener - Callback function to handle notifications - * @returns Function to remove the listener - */ - abstract onNotification(listener: NotificationCallback): () => void; - /** * Invokes an RPC method with the specified options. * @@ -93,36 +46,24 @@ export abstract class MultichainSDKBase { */ abstract invokeMethod(options: InvokeMethodOptions): Promise; - /** - * Storage client instance for persisting SDK data. - */ - abstract readonly storage: StoreClient; + constructor(protected readonly options: MultichainOptions) { + super(); + } } /* c8 ignore end */ -export type { SessionData } from '@metamask/multichain-api-client'; - -/** - * Base options for Multichain SDK configuration. - * - * This type includes the core configuration options excluding storage, - * which is handled separately in the full SDK options. - */ -export type MultichainSDKBaseOptions = Pick; - -/** - * Complete options for Multichain SDK configuration. - * - * This type extends the base options with storage configuration, - * providing all necessary options for SDK initialization. - */ -export type MultichainSDKOptions = MultichainSDKBaseOptions & { - /** Storage client for persisting SDK data */ - storage: StoreClient; -}; - -export type CreateMultichainFN = (options: MultichainSDKBaseOptions) => Promise; +export function getTransportType(type: string): TransportType { + switch (type) { + case 'browser': + return TransportType.Browser; + case 'mwp': + return TransportType.MPW; + default: + return TransportType.UNKNOWN; + } +} export * from './api/constants'; export * from './api/infura'; export type * from './api/types'; +export type * from './types'; diff --git a/packages/sdk-multichain/src/domain/multichain/types.ts b/packages/sdk-multichain/src/domain/multichain/types.ts new file mode 100644 index 000000000..93f732bf9 --- /dev/null +++ b/packages/sdk-multichain/src/domain/multichain/types.ts @@ -0,0 +1,68 @@ +import type { StoreClient } from '../store'; +import type { ModalFactory } from '../ui'; +import type { MultichainCore } from '.'; +import type { RPC_URLS_MAP } from './api/types'; + +export type { SessionData } from '@metamask/multichain-api-client'; + +/** + * Configuration settings for the dapp using the SDK. + * + * This type allows for two variants of dapp configuration: + * - Using a regular icon URL + * - Using a base64-encoded icon + */ +export type DappSettings = { + name?: string; + url?: string; +} & ({ iconUrl?: string } | { base64Icon?: string }); + +/** + * Constructor options for creating a Multichain SDK instance. + * + * This type defines all the configuration options available when + * initializing the SDK, including dapp settings, API configuration, + * analytics, storage, UI preferences, and transport options. + */ +export type MultichainOptions = { + /** Dapp identification and branding settings */ + dapp: DappSettings; + /** Optional API configuration for external services */ + api?: { + /** The Infura API key to use for RPC requests */ + infuraAPIKey?: string; + /** A map of RPC URLs to use for read-only requests */ + readonlyRPCMap?: RPC_URLS_MAP; + }; + /** Analytics configuration */ + analytics?: { enabled: false } | { enabled: true; integrationType: string }; + /** Storage client for persisting SDK data */ + storage: StoreClient; + /** UI configuration options */ + ui: { + factory: ModalFactory; + headless?: boolean; + preferExtension?: boolean; + preferDesktop?: boolean; + }; + mobile?: { + useDeeplink?: boolean; + }; + /** Optional transport configuration */ + transport?: { + /** Extension ID for browser extension transport */ + extensionId?: string; + }; +}; + +export type MultiChainFNOptions = Omit & { + ui?: Omit; +}; + +/** + * Complete options for Multichain SDK configuration. + * + * This type extends the base options with storage configuration, + * providing all necessary options for SDK initialization. + */ +export type CreateMultichainFN = (options: MultiChainFNOptions) => Promise; diff --git a/packages/sdk-multichain/src/domain/platform/index.ts b/packages/sdk-multichain/src/domain/platform/index.ts index dca963a62..5e997ee57 100644 --- a/packages/sdk-multichain/src/domain/platform/index.ts +++ b/packages/sdk-multichain/src/domain/platform/index.ts @@ -64,3 +64,13 @@ export function getPlatformType() { } return PlatformType.DesktopWeb; } + +/** + * Check if MetaMask extension is installed + */ +export function isMetamaskExtensionInstalled(): boolean { + if (typeof window === 'undefined') { + return false; + } + return Boolean(window.ethereum?.isMetaMask); +} diff --git a/packages/sdk-multichain/src/domain/store/adapter.ts b/packages/sdk-multichain/src/domain/store/adapter.ts index 8f35897a5..68a11b772 100644 --- a/packages/sdk-multichain/src/domain/store/adapter.ts +++ b/packages/sdk-multichain/src/domain/store/adapter.ts @@ -1,4 +1,5 @@ /* c8 ignore start */ +// biome-ignore lint/suspicious/noExplicitAny: Needed here export type StoreOptions = Record; export abstract class StoreAdapter { diff --git a/packages/sdk-multichain/src/domain/store/client.ts b/packages/sdk-multichain/src/domain/store/client.ts index cac1f9d95..8548ea1fb 100644 --- a/packages/sdk-multichain/src/domain/store/client.ts +++ b/packages/sdk-multichain/src/domain/store/client.ts @@ -1,9 +1,16 @@ /* c8 ignore start */ +import type { TransportType } from '../multichain'; export abstract class StoreClient { - abstract getAnonId(): Promise; + abstract getAnonId(): Promise; + abstract getExtensionId(): Promise; abstract setExtensionId(extensionId: string): Promise; + + abstract getTransport(): Promise; + abstract setTransport(transport: TransportType): Promise; + abstract removeTransport(): Promise; + abstract setAnonId(anonId: string): Promise; abstract removeExtensionId(): Promise; abstract removeAnonId(): Promise; diff --git a/packages/sdk-multichain/src/domain/ui/factory.ts b/packages/sdk-multichain/src/domain/ui/factory.ts new file mode 100644 index 000000000..4d57854ca --- /dev/null +++ b/packages/sdk-multichain/src/domain/ui/factory.ts @@ -0,0 +1,63 @@ +import type { Transport } from '@metamask/multichain-api-client'; +import type { CaipAccountId } from '@metamask/utils'; +import type { MultichainOptions, Scope, SessionData } from '../multichain'; +import type { Modal } from './types'; + +export type ModalTypes = 'installModal' | 'selectModal' | 'pendingModal'; +/** + * Record type that maps modal names to their corresponding Modal instances. + * Used to store different types of modals that can be created by the factory. + */ +// biome-ignore lint/suspicious/noExplicitAny: Needed here +export type FactoryModals = Record>; + +/** + * Options passed when establishing a connection through a modal. + * Contains the scopes (permissions) and account IDs involved in the connection. + */ +export type ModalFactoryConnectOptions = { + scopes: Scope[]; + caipAccountIds: CaipAccountId[]; +}; + +/** + * Configuration options for the modal factory. + * Combines mobile settings from SDK options with UI preferences and connection handling. + */ +export type ModalFactoryOptions = Pick & { + ui: { + headless?: boolean; // Whether to run without UI + preferExtension?: boolean; // Whether to prefer browser extension + preferDesktop?: boolean; // Whether to prefer desktop wallet + }; + onConnection: (transport: Transport, options: ModalFactoryConnectOptions) => Promise; + getCurrentSession: () => Promise; + connection?: ModalFactoryConnectOptions; +}; + +/** + * Abstract base class for creating and managing modals across different platforms. + * Provides platform detection and common functionality for modal management. + * + * @template T - Type of modals this factory can create, defaults to FactoryModals + */ +export abstract class ModalFactory { + abstract renderInstallModal(link: string, preferDesktop: boolean): Promise; + abstract renderSelectModal(link: string, preferDesktop: boolean, connect: () => Promise): Promise; + abstract renderPendingModal(): Promise; + /** + * Creates a new modal factory instance. + * @param options - The modals configuration object + */ + constructor(protected readonly options: T) { + this.validateModals(); + } + + private validateModals() { + const requiredModals = ['installModal', 'selectModal', 'pendingModal']; + const missingModals = requiredModals.filter((modal) => !this.options[modal as ModalTypes]); + if (missingModals.length > 0) { + throw new Error(`Missing required modals: ${missingModals.join(', ')}`); + } + } +} diff --git a/packages/sdk-multichain/src/domain/ui/index.ts b/packages/sdk-multichain/src/domain/ui/index.ts new file mode 100644 index 000000000..93f2263b5 --- /dev/null +++ b/packages/sdk-multichain/src/domain/ui/index.ts @@ -0,0 +1,2 @@ +export * from './factory'; +export * from './types'; diff --git a/packages/sdk-multichain/src/domain/ui/types.ts b/packages/sdk-multichain/src/domain/ui/types.ts new file mode 100644 index 000000000..ae5e475c7 --- /dev/null +++ b/packages/sdk-multichain/src/domain/ui/types.ts @@ -0,0 +1,56 @@ +import type { Components } from '@metamask/sdk-install-modal-web'; + +export interface InstallWidgetProps extends Components.MmInstallModal { + parentElement?: Element; + onClose: (shouldTerminate?: boolean) => void; + metaMaskInstaller: { + startDesktopOnboarding: () => void; + }; + //TODO: remove this as its no longer needed + // biome-ignore lint/suspicious/noExplicitAny: will be removed soon + onAnalyticsEvent: (event: { event: any; params?: Record }) => void; +} + +export interface PendingWidgetProps extends Components.MmPendingModal { + parentElement?: Element; + onClose: () => void; + onDisconnect?: () => void; + updateOTPValue: (otpValue: string) => void; +} + +export interface SelectWidgetProps extends Components.MmSelectModal { + parentElement?: Element; + onClose: (shouldTerminate?: boolean) => void; + connect: () => void; +} + +export type RenderedModal = { + mount(qrcodeLink?: string): void; + unmount(shouldTerminate?: boolean): void; + sync?(...params: unknown[]): void; +}; + +/** + * Abstract Modal class with shared functionality across all models + */ + +// biome-ignore lint/suspicious/noExplicitAny: Expected here +export abstract class Modal> { + abstract instance?: HTMLMmInstallModalElement | HTMLMmSelectModalElement | HTMLMmPendingModalElement; + abstract render(options: T): Promise; + + updateQRCode(link: string) { + const installModal = this.instance?.querySelector('mm-install-modal') as HTMLMmInstallModalElement | null; + if (installModal) { + installModal.link = link; + } else { + const selectModal = this.instance?.querySelector('mm-select-modal') as HTMLMmSelectModalElement | null; + if (selectModal) { + selectModal.link = link; + } + } + } +} + +export abstract class AbstractInstallModal extends Modal {} +export abstract class AbstractPendingModal extends Modal {} diff --git a/packages/sdk-multichain/src/utis/base64/index.test.ts b/packages/sdk-multichain/src/domain/utils/base64/index.test.ts similarity index 92% rename from packages/sdk-multichain/src/utis/base64/index.test.ts rename to packages/sdk-multichain/src/domain/utils/base64/index.test.ts index cf2d37747..f7245003e 100644 --- a/packages/sdk-multichain/src/utis/base64/index.test.ts +++ b/packages/sdk-multichain/src/domain/utils/base64/index.test.ts @@ -1,3 +1,5 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ import * as t from 'vitest'; import { base64Encode } from '.'; diff --git a/packages/sdk-multichain/src/utis/base64/index.ts b/packages/sdk-multichain/src/domain/utils/base64/index.ts similarity index 100% rename from packages/sdk-multichain/src/utis/base64/index.ts rename to packages/sdk-multichain/src/domain/utils/base64/index.ts diff --git a/packages/sdk-multichain/src/domain/utils/index.ts b/packages/sdk-multichain/src/domain/utils/index.ts new file mode 100644 index 000000000..d9703cabe --- /dev/null +++ b/packages/sdk-multichain/src/domain/utils/index.ts @@ -0,0 +1,6 @@ +export * from './base64'; +import packageJson from '../../../package.json'; + +export function getVersion() { + return packageJson.version; +} diff --git a/packages/sdk-multichain/src/fixtures.test.ts b/packages/sdk-multichain/src/fixtures.test.ts new file mode 100644 index 000000000..aca281ce0 --- /dev/null +++ b/packages/sdk-multichain/src/fixtures.test.ts @@ -0,0 +1,242 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +/** + * Test fixtures and utilities for the Multichain SDK tests + * This file is excluded from test discovery via vitest.config.ts + */ + +import type { Transport } from '@metamask/multichain-api-client'; +import * as t from 'vitest'; +import { vi } from 'vitest'; +import type { MultiChainFNOptions, MultichainCore, SessionData } from '../src/domain'; +import { MultichainSDK } from '../src/multichain'; + +// Mock logger at the top level +vi.mock('./domain/logger', () => { + const __mockLogger = vi.fn(); + return { + createLogger: vi.fn(() => __mockLogger), + enableDebug: vi.fn(() => {}), + isEnabled: vi.fn(() => true), + __mockLogger, + }; +}); + +// Mock analytics at the top level +vi.mock('@metamask/sdk-analytics', () => ({ + analytics: { + setGlobalProperty: vi.fn(), + enable: vi.fn(), + track: vi.fn(), + }, +})); + +// Mock the MWPClientTransport +vi.mock('./multichain/client', () => ({ + MWPClientTransport: { + connect: vi.fn(() => Promise.resolve()), + disconnect: vi.fn(() => Promise.resolve()), + isConnected: vi.fn(() => false), + request: vi.fn(() => Promise.resolve({})), + onNotification: vi.fn(() => () => {}), + }, +})); + +// Mock multichain client at the top level with factory functions +// Mock multichain client at the top level with factory functions +vi.mock('@metamask/multichain-api-client', () => { + const invokeResponse = vi.fn(); + const mockMultichainClient = { + createSession: vi.fn(), + getSession: vi.fn(), + revokeSession: vi.fn(), + invokeMethod: invokeResponse, + extendsRpcApi: vi.fn(), + onNotification: vi.fn(), + }; + return { + getMultichainClient: vi.fn(() => mockMultichainClient), + // Export the mocks so tests can access them + __mockMultichainClient: mockMultichainClient, + __mockInvokeResponse: invokeResponse, + }; +}); + +export type NativeStorageStub = { + platform: 'web' | 'rn' | 'node'; + data: Map; + getItem: t.Mock<(key: string) => Promise>; + setItem: t.Mock<(key: string, value: string) => Promise>; + deleteItem: t.Mock<(key: string) => Promise>; + clear: t.Mock<() => void>; +}; + +export type MockedData = { + initSpy: t.MockInstance; + setupAnalyticsSpy: t.MockInstance; + emitSpy: t.MockInstance; + nativeStorageStub: NativeStorageStub; + mockTransport: t.Mocked; + mockMultichainClient: any; +}; + +export type TestSuiteOptions = { + platform: string; + createSDK: Options['createSDK']; + options: Options['options']; + beforeEach: () => Promise; + afterEach: (mocks: MockedData) => Promise; + storage: NativeStorageStub; +}; + +export type Options = { + platform: 'web' | 'node' | 'rn'; + options: T; + createSDK: (options: T) => Promise; + setupMocks?: (options: NativeStorageStub) => void; + cleanupMocks?: () => void; + tests: (options: TestSuiteOptions) => void; +}; + +export type CreateTestFN = (options: Options) => void; + +// Mock session data for testing +export const mockSessionData: SessionData = { + sessionScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + }, + }, + expiry: new Date(Date.now() + 3600000).toISOString(), +}; + +export const createTest: CreateTestFN = ({ platform, options, createSDK, setupMocks, cleanupMocks, tests }) => { + let nativeStorageStub!: NativeStorageStub; + let setupAnalyticsSpy!: t.MockInstance; + let initSpy!: t.MockInstance; + let emitSpy!: t.MockInstance; + + const initialTransportSpy = { + _isConnected: false, + connect: vi.fn(() => { + initialTransportSpy._isConnected = true; + }), + disconnect: vi.fn(() => { + initialTransportSpy._isConnected = false; + }), + isConnected: vi.fn(() => initialTransportSpy._isConnected), + request: vi.fn(), + onNotification: vi.fn(() => { + return () => {}; + }), + }; + + // Helper function to reset transport state + const resetTransportSpy = () => { + initialTransportSpy._isConnected = false; + initialTransportSpy.connect.mockClear(); + initialTransportSpy.disconnect.mockClear(); + initialTransportSpy.isConnected.mockClear(); + initialTransportSpy.request.mockClear(); + initialTransportSpy.onNotification.mockClear(); + }; + + async function beforeEach() { + // Clear all mocks first + t.vi.clearAllMocks(); + + // Reset transport spy state + resetTransportSpy(); + + // Reset global SDK state + MultichainSDK.resetGlobals(); + + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + // Reset multichain client mocks with default implementations + mockMultichainClient.createSession.mockResolvedValue(mockSessionData); + mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + + // Create storage stub + nativeStorageStub = { + platform, + data: new Map(), + getItem: vi.fn((key: string) => Promise.resolve(nativeStorageStub.data.get(key) || null)), + setItem: vi.fn((key: string, value: string) => { + nativeStorageStub.data.set(key, value); + }), + deleteItem: vi.fn((key: string) => { + nativeStorageStub.data.delete(key); + }), + clear: vi.fn(() => { + nativeStorageStub.data.clear(); + }), + }; + + // Set debug flag + nativeStorageStub.data.set('DEBUG', 'metamask-sdk:*'); + + // Create spies for SDK methods + initSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'init'); + setupAnalyticsSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'setupAnalytics'); + emitSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'emit'); + t.vi.spyOn(MultichainSDK.prototype as any, 'initialTransport').mockResolvedValue(initialTransportSpy); + t.vi.spyOn(MultichainSDK.prototype as any, 'getTransportForPlatformType').mockReturnValue(initialTransportSpy); + // Setup platform-specific mocks + setupMocks?.(nativeStorageStub); + + return { + initSpy, + setupAnalyticsSpy, + emitSpy, + nativeStorageStub, + mockTransport: initialTransportSpy as any, + mockMultichainClient, + }; + } + + async function afterEach(mocks: MockedData) { + // Clear storage + mocks.nativeStorageStub.data.clear(); + + // Restore spies + mocks.setupAnalyticsSpy?.mockRestore(); + mocks.initSpy?.mockRestore(); + mocks.emitSpy?.mockRestore(); + + // Reset transport spy state + resetTransportSpy(); + + // Reset global SDK state + MultichainSDK.resetGlobals(); + + // Reset multichain client mock functions + if (mocks.mockMultichainClient) { + mocks.mockMultichainClient.createSession.mockClear(); + mocks.mockMultichainClient.getSession.mockClear(); + mocks.mockMultichainClient.revokeSession.mockClear(); + mocks.mockMultichainClient.invokeMethod.mockClear(); + mocks.mockMultichainClient.onNotification.mockClear(); + } + + // Clear all mocks + t.vi.clearAllMocks(); + t.vi.resetAllMocks(); + + // Run custom cleanup + cleanupMocks?.(); + } + + tests({ + platform, + createSDK, + options, + beforeEach, + afterEach, + storage: nativeStorageStub, + }); +}; diff --git a/packages/sdk-multichain/src/globals.d.ts b/packages/sdk-multichain/src/globals.d.ts index 2520b33b3..c75f611cc 100644 --- a/packages/sdk-multichain/src/globals.d.ts +++ b/packages/sdk-multichain/src/globals.d.ts @@ -1,5 +1,6 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: required in here */ declare module '@paulmillr/qr'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export declare const mmsdk: any; declare global { interface Window { diff --git a/packages/sdk-multichain/src/index.browser.ts b/packages/sdk-multichain/src/index.browser.ts index 38ce615d5..103436a6e 100644 --- a/packages/sdk-multichain/src/index.browser.ts +++ b/packages/sdk-multichain/src/index.browser.ts @@ -1,17 +1,22 @@ import type { CreateMultichainFN } from './domain'; import { MultichainSDK } from './multichain'; import { Store } from './store'; +import { UIModule } from './ui'; -export type * from './domain'; +export * from './domain'; export const createMetamaskSDK: CreateMultichainFN = async (options) => { const { StoreAdapterWeb } = await import('./store/adapters/web'); + const uiModules = await import('./ui/web'); const adapter = new StoreAdapterWeb(); + const storage = new Store(adapter); + const factory = new UIModule(uiModules); return MultichainSDK.create({ ...options, - storage: new Store(adapter), + storage, ui: { - headless: options.ui?.headless ?? false, + ...options.ui, + factory, }, }); }; diff --git a/packages/sdk-multichain/src/index.native.ts b/packages/sdk-multichain/src/index.native.ts index 69315c280..4f6143d42 100644 --- a/packages/sdk-multichain/src/index.native.ts +++ b/packages/sdk-multichain/src/index.native.ts @@ -1,17 +1,22 @@ import type { CreateMultichainFN } from './domain'; import { MultichainSDK } from './multichain'; import { Store } from './store'; +import { UIModule } from './ui'; -export type * from './domain'; +export * from './domain'; export const createMetamaskSDK: CreateMultichainFN = async (options) => { const { StoreAdapterRN } = await import('./store/adapters/rn'); + const uiModules = await import('./ui/rn'); const adapter = new StoreAdapterRN(); + const storage = new Store(adapter); + const factory = new UIModule(uiModules); return MultichainSDK.create({ ...options, - storage: new Store(adapter), + storage, ui: { - headless: options.ui?.headless ?? false, // React Native can show UI + ...options.ui, + factory, }, }); }; diff --git a/packages/sdk-multichain/src/index.node.ts b/packages/sdk-multichain/src/index.node.ts index 822568c79..786c3fc92 100644 --- a/packages/sdk-multichain/src/index.node.ts +++ b/packages/sdk-multichain/src/index.node.ts @@ -1,17 +1,22 @@ import type { CreateMultichainFN } from './domain'; import { MultichainSDK } from './multichain'; import { Store } from './store'; +import { UIModule } from './ui'; -export type * from './domain'; +export * from './domain'; export const createMetamaskSDK: CreateMultichainFN = async (options) => { const { StoreAdapterNode } = await import('./store/adapters/node'); + const uiModules = await import('./ui/node'); const adapter = new StoreAdapterNode(); + const storage = new Store(adapter); + const factory = new UIModule(uiModules); return MultichainSDK.create({ ...options, - storage: new Store(adapter), + storage, ui: { - headless: true, // Node.js defaults to headless + ...options.ui, + factory, }, }); }; diff --git a/packages/sdk-multichain/src/index.test.ts b/packages/sdk-multichain/src/index.test.ts deleted file mode 100644 index 31a8dbb83..000000000 --- a/packages/sdk-multichain/src/index.test.ts +++ /dev/null @@ -1,808 +0,0 @@ -import type { SessionData } from '@metamask/multichain-api-client'; -import * as analyticsModule from '@metamask/sdk-analytics'; -import { JSDOM as Page } from 'jsdom'; -import * as t from 'vitest'; -import { vi } from 'vitest'; -import type { MultichainSDKBase, MultichainSDKBaseOptions } from './domain'; -import * as loggerModule from './domain/logger'; -import type { Scope } from './domain/multichain/api/types'; -import { createMetamaskSDK as createMetamaskSDKWeb } from './index.browser'; -import { MultichainSDK } from './multichain'; - -type NativeStorageStub = { - data: Map; - getItem: t.Mock<(key: string) => string | null>; - setItem: t.Mock<(key: string, value: string) => void>; - removeItem: t.Mock<(key: string) => void>; - clear: t.Mock<() => void>; -}; - -// Mock the multichain-api-client with proper implementations -vi.mock('@metamask/multichain-api-client', () => { - const mockTransport = { - connect: vi.fn().mockResolvedValue(true), - disconnect: vi.fn().mockResolvedValue(undefined), - isConnected: true, - request: vi.fn(), - onNotification: vi.fn().mockReturnValue(() => {}), - }; - - const mockMultichainClient = { - createSession: vi.fn(), - getSession: vi.fn(), - revokeSession: vi.fn(), - invokeMethod: vi.fn(), - extendsRpcApi: vi.fn(), - onNotification: vi.fn().mockReturnValue(() => {}), - }; - - const mockGetDefaultTransport = vi.fn().mockReturnValue(mockTransport); - const mockGetMultichainClient = vi.fn().mockReturnValue(mockMultichainClient); - - return { - getDefaultTransport: mockGetDefaultTransport, - getMultichainClient: mockGetMultichainClient, - __mockTransport: mockTransport, - __mockMultichainClient: mockMultichainClient, - __mockGetDefaultTransport: mockGetDefaultTransport, - __mockGetMultichainClient: mockGetMultichainClient, - }; -}); - -vi.mock('./domain/logger', () => { - const mockLogger = vi.fn(); - return { - createLogger: vi.fn(() => mockLogger), - enableDebug: vi.fn(() => {}), - isEnabled: vi.fn(() => true), - __mockLogger: mockLogger, - }; -}); - -t.vi.mock('@metamask/sdk-analytics'); - -function createMultiplatformTestCase( - platform: 'web' | 'node' | 'rn', - options: MultichainSDKBaseOptions, - createSDK: (options: MultichainSDKBaseOptions) => Promise, - setupMocks?: (options: NativeStorageStub) => void, - cleanupMocks?: () => void, -) { - t.describe(`Testing MultichainSDK in ${platform}`, () => { - let sdk: MultichainSDKBase; - let setupAnalyticsSpy: any; - let initSpy: any; - let nativeStorageStub: NativeStorageStub; - - // Mock session data for testing - const mockSessionData: SessionData = { - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction', 'eth_accounts'], - notifications: ['accountsChanged', 'chainChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - expiry: new Date(Date.now() + 3600000).toISOString(), - }; - - t.beforeEach(async () => { - (loggerModule as any).__mockLogger.mockReset(); - setupAnalyticsSpy?.mockRestore(); - initSpy?.mockRestore(); - t.vi.clearAllMocks(); - t.vi.resetModules(); - t.vi.unstubAllGlobals(); - - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - const mockGetDefaultTransport = (multichainModule as any).__mockGetDefaultTransport; - const mockGetMultichainClient = (multichainModule as any).__mockGetMultichainClient; - - // Reset transport mocks - mockTransport.connect.mockResolvedValue(true); - mockTransport.disconnect.mockResolvedValue(undefined); - mockTransport.isConnected = true; - mockTransport.request.mockResolvedValue({}); - mockTransport.onNotification.mockReturnValue(() => {}); - - // Reset multichain client mocks - mockMultichainClient.createSession.mockResolvedValue(mockSessionData); - mockMultichainClient.getSession.mockResolvedValue(mockSessionData); - mockMultichainClient.revokeSession.mockResolvedValue(undefined); - mockMultichainClient.invokeMethod.mockResolvedValue({}); - mockMultichainClient.onNotification.mockReturnValue(() => {}); - - // Reset factory function mocks - mockGetDefaultTransport.mockReturnValue(mockTransport); - mockGetMultichainClient.mockReturnValue(mockMultichainClient); - - nativeStorageStub = { - data: new Map(), - getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null), - setItem: t.vi.fn((key: string, value: string) => { - nativeStorageStub.data.set(key, value); - }), - removeItem: t.vi.fn((key: string) => { - nativeStorageStub.data.delete(key); - }), - clear: t.vi.fn(() => { - nativeStorageStub.data.clear(); - }), - }; - - nativeStorageStub.data.set('DEBUG', 'metamask-sdk:*'); - - setupMocks?.(nativeStorageStub); - - // Mock analytics methods - t.vi.mocked(analyticsModule.analytics).setGlobalProperty = t.vi.fn(); - t.vi.mocked(analyticsModule.analytics).enable = t.vi.fn(); - t.vi.mocked(analyticsModule.analytics).track = t.vi.fn(); - setupAnalyticsSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'setupAnalytics'); - initSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'init'); - }); - - t.afterEach(() => { - nativeStorageStub.data.clear(); - cleanupMocks?.(); - // Reset mock implementations and call history - (loggerModule as any).__mockLogger.mockReset(); - setupAnalyticsSpy?.mockRestore(); - initSpy?.mockRestore(); - t.vi.clearAllMocks(); - t.vi.resetModules(); - t.vi.unstubAllGlobals(); - }); - - t.describe('init', () => { - t.it(`${platform} should call setupAnalytics if analytics is ENABLED and trigger analytics.enable and init evt`, async () => { - options.analytics.enabled = true; - sdk = await createSDK(options); - t.expect(sdk).toBeDefined(); - t.expect(initSpy).toHaveBeenCalled(); - t.expect(setupAnalyticsSpy).toHaveBeenCalled(); - t.expect(analyticsModule.analytics.enable).toHaveBeenCalled(); - t.expect(analyticsModule.analytics.track).toHaveBeenCalledWith('sdk_initialized', {}); - }); - - t.it(`${platform} should NOT call analytics.enable if analytics is DISABLED`, async () => { - options.analytics.enabled = false; - sdk = await createSDK(options); - t.expect(sdk).toBeDefined(); - t.expect(initSpy).toHaveBeenCalled(); - t.expect(setupAnalyticsSpy).toHaveBeenCalled(); - t.expect(analyticsModule.analytics.enable).not.toHaveBeenCalled(); - t.expect(analyticsModule.analytics.track).not.toHaveBeenCalled(); - }); - - t.it(`${platform} should call init and setupAnalytics with logger configuration`, async () => { - const mockLogger = (loggerModule as any).__mockLogger; - - sdk = await createSDK(options); - t.expect(sdk).toBeDefined(); - t.expect(mockLogger).not.toHaveBeenCalled(); - - t.expect(initSpy).toHaveBeenCalled(); - t.expect(setupAnalyticsSpy).toHaveBeenCalled(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(loggerModule.enableDebug).toHaveBeenCalledWith('metamask-sdk:core'); - }); - t.it(`${platform} should properly initialize provider and get session during init`, async () => { - sdk = await createSDK(options); - - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - const mockGetDefaultTransport = (multichainModule as any).__mockGetDefaultTransport; - const mockGetMultichainClient = (multichainModule as any).__mockGetMultichainClient; - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(sdk.session).toEqual(mockSessionData); - - // Verify the provider was created with the correct transport - t.expect(mockGetDefaultTransport).toHaveBeenCalled(); - t.expect(mockGetMultichainClient).toHaveBeenCalledWith({ transport: mockTransport }); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - }); - t.it(`${platform} Should gracefully handle init errors by just logging them and return non initialized sdk`, async () => { - const testError = new Error('Test error'); - initSpy.mockImplementation(() => { - throw testError; - }); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(false); - - // Access the mock logger from the module - const mockLogger = (loggerModule as any).__mockLogger; - - // Verify that the logger was called with the error - t.expect(mockLogger).toHaveBeenCalledWith('MetaMaskSDK error during initialization', testError); - }); - }); - - t.describe('session', () => { - t.it(`${platform} should handle session upgrades`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = false; - mockTransport.connect.mockResolvedValue(true); - mockMultichainClient.getSession.mockResolvedValue(mockSessionData); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(sdk.session).not.toBeUndefined(); - - const mockedSessionUpgradeData: SessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction', 'eth_accounts'], - notifications: ['accountsChanged', 'chainChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - const scopes = ['eip155:137'] as Scope[]; - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockTransport.connect).toHaveBeenCalled(); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:137': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(mockedSessionUpgradeData); - }); - - t.it(`${platform} should handle session retrieval when no session exists`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - // Mock no session scenario - mockMultichainClient.getSession.mockResolvedValue(undefined); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(sdk.session).toBeUndefined(); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - }); - - t.it(`${platform} should handle provider errors during session retrieval`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - const sessionError = new Error('Session error'); - - // Clear previous calls and set up the error mock - t.vi.clearAllMocks(); - mockMultichainClient.getSession.mockRejectedValue(sessionError); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(false); - - // Access the mock logger from the module - const mockLogger = (loggerModule as any).__mockLogger; - - // Verify that the logger was called with the error - t.expect(mockLogger).toHaveBeenCalledWith('MetaMaskSDK error during initialization', sessionError); - }); - }); - - t.describe(`${platform} connect method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should connect transport and create session when not connected`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - // Configure mocks for this specific test BEFORE creating SDK - mockTransport.isConnected = false; - mockTransport.connect.mockResolvedValue(true); - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - // Create a new SDK instance with the mock configured correctly - const testSdk = await createSDK(options); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await testSdk.connect(scopes, caipAccountIds); - - t.expect(mockTransport.connect).toHaveBeenCalled(); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:1': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should skip transport connection when already connected`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - // Mock transport as already connected - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockTransport.connect).not.toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalled(); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should handle invalid CAIP account IDs gracefully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - // Mock console.error to capture invalid account ID errors - const consoleErrorSpy = t.vi.spyOn(console, 'error').mockImplementation(() => {}); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: [], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['invalid-account-id', 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid CAIP account ID: "invalid-account-id"', t.expect.any(Error)); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:1': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - - consoleErrorSpy.mockRestore(); - }); - - t.it(`${platform} should return existing session when scopes don't overlap`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - - const existingSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:137': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - mockMultichainClient.getSession.mockResolvedValue(existingSessionData); - - const scopes = ['eip155:137'] as Scope[]; // Different scope than existing session - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).not.toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); - t.expect(result).toEqual(existingSessionData); - }); - - t.it(`should ${platform} simulate sesion upgrade by adding new session scopes to connect`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - - const existingSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - mockMultichainClient.getSession.mockResolvedValue(existingSessionData); - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:137'] as Scope[]; // Same scope as existing session - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:137': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should handle multiple scopes and accounts correctly`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - 'eip155:137': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:137:0x9876543210fedcba9876543210fedcba98765432'], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:1', 'eip155:137'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678', 'eip155:137:0x9876543210fedcba9876543210fedcba98765432'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:1': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - 'eip155:137': { - methods: [], - notifications: [], - accounts: ['0x9876543210fedcba9876543210fedcba98765432'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should handle transport connection errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - - // Reset the transport mock to simulate connection failure - mockTransport.isConnected = false; - const connectionError = new Error('Failed to connect transport'); - mockTransport.connect.mockRejectedValue(connectionError); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to connect transport'); - }); - - t.it(`${platform} should handle session creation errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const sessionError = new Error('Failed to create session'); - mockMultichainClient.createSession.mockRejectedValue(sessionError); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to create session'); - }); - - t.it(`${platform} should handle session revocation errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - - const existingSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - mockMultichainClient.getSession.mockResolvedValue(existingSessionData); - - const revocationError = new Error('Failed to revoke session'); - mockMultichainClient.revokeSession.mockRejectedValue(revocationError); - - const scopes = ['eip155:137'] as Scope[]; // Same scope as existing session to trigger revocation - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to revoke session'); - }); - }); - - t.describe(`${platform} disconnect method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should disconnect transport successfully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - - mockTransport.disconnect.mockResolvedValue(undefined); - - await sdk.disconnect(); - - t.expect(mockTransport.disconnect).toHaveBeenCalled(); - }); - - t.it(`${platform} should handle disconnect errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - - const disconnectError = new Error('Failed to disconnect transport'); - mockTransport.disconnect.mockRejectedValue(disconnectError); - - await t.expect(sdk.disconnect()).rejects.toThrow('Failed to disconnect transport'); - }); - }); - - t.describe(`${platform} onNotification method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should register notification listener`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - const mockListener = t.vi.fn(); - const mockUnsubscribe = t.vi.fn(); - mockMultichainClient.onNotification.mockReturnValue(mockUnsubscribe); - - const unsubscribe = sdk.onNotification(mockListener); - - t.expect(mockMultichainClient.onNotification).toHaveBeenCalledWith(mockListener); - t.expect(unsubscribe).toBe(mockUnsubscribe); - }); - }); - - t.describe(`${platform} revokeSession method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should revoke session successfully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockMultichainClient.revokeSession.mockResolvedValue(undefined); - - await sdk.revokeSession(); - - t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); - }); - - t.it(`${platform} should handle revoke session errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - const revokeError = new Error('Failed to revoke session'); - mockMultichainClient.revokeSession.mockRejectedValue(revokeError); - - await t.expect(sdk.revokeSession()).rejects.toThrow('Failed to revoke session'); - }); - }); - - t.describe(`${platform} invokeMethod method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should invoke method successfully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - // Mock the RPCClient response - const mockResponse = { result: 'success' }; - - // We need to mock the invokeMethod on the rpcClient - // Since rpcClient is private, we'll access it through the sdk instance - const mockRpcClient = { - invokeMethod: t.vi.fn().mockResolvedValue(mockResponse), - }; - - // Replace the rpcClient in the sdk instance - (sdk as any).rpcClient = mockRpcClient; - - const options = { - scope: 'eip155:1', - method: 'eth_accounts', - params: [], - } as any; - - const result = await sdk.invokeMethod(options); - - t.expect(mockRpcClient.invokeMethod).toHaveBeenCalledWith(options); - t.expect(result).toEqual(mockResponse); - }); - - t.it(`${platform} should handle invoke method errors`, async () => { - // Mock the RPCClient response - const mockError = new Error('Failed to invoke method'); - - // We need to mock the invokeMethod on the rpcClient - const mockRpcClient = { - invokeMethod: t.vi.fn().mockRejectedValue(mockError), - }; - - // Replace the rpcClient in the sdk instance - (sdk as any).rpcClient = mockRpcClient; - - const options = { - scope: 'eip155:1', - method: 'eth_accounts', - params: [], - } as any; - - await t.expect(sdk.invokeMethod(options)).rejects.toThrow('Failed to invoke method'); - }); - }); - }); -} - -t.describe('MultichainSDK', () => { - createMultiplatformTestCase( - 'web', - { - dapp: { - name: 'Test Dapp', - url: 'https://test.dapp', - }, - analytics: { - enabled: false, - }, - ui: { - headless: false, - }, - }, - createMetamaskSDKWeb, - (nativeStorageStub) => { - const dom = new Page('

Hello world

', { url: 'https://dapp.io/' }); - const globalStub = { - ...dom.window, - addEventListener: t.vi.fn(), - removeEventListener: t.vi.fn(), - localStorage: nativeStorageStub, - }; - t.vi.stubGlobal('navigator', { - ...dom.window.navigator, - product: 'Chrome', - }); - t.vi.stubGlobal('window', globalStub); - t.vi.stubGlobal('location', dom.window.location); - }, - ); -}); diff --git a/packages/sdk-multichain/src/init.test.ts b/packages/sdk-multichain/src/init.test.ts new file mode 100644 index 000000000..13fb901e0 --- /dev/null +++ b/packages/sdk-multichain/src/init.test.ts @@ -0,0 +1,205 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import fs from 'node:fs'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { JSDOM as Page } from 'jsdom'; +import * as t from 'vitest'; +import type { MultiChainFNOptions, MultichainCore } from './domain'; +import { createTest, type MockedData, mockSessionData, type TestSuiteOptions } from './fixtures.test'; +import { createMetamaskSDK as createMetamaskSDKWeb } from './index.browser'; +import { createMetamaskSDK as createMetamaskSDKRN } from './index.native'; +import { createMetamaskSDK as createMetamaskSDKNode } from './index.node'; +import { MultichainSDK } from './multichain'; +import * as nodeStorage from './store/adapters/node'; +import * as rnStorage from './store/adapters/rn'; +import * as webStorage from './store/adapters/web'; + +// Carefull, order of import matters to keep mocks working +import { analytics } from '@metamask/sdk-analytics'; +import * as loggerModule from './domain/logger'; +import { Store } from './store'; + +function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { + const { beforeEach, afterEach } = options; + const originalSdkOptions = sdkOptions; + let sdk: MultichainCore; + + t.describe(`${platform} tests`, () => { + let mockedData: MockedData; + let testOptions: T; + const transportString = platform === 'web' ? 'browser' : 'mwp'; + + t.beforeEach(async () => { + mockedData = await beforeEach(); + testOptions = { + ...originalSdkOptions, + analytics: { + ...originalSdkOptions.analytics, + enabled: platform !== 'node', + integrationType: 'test', + }, + storage: new Store(mockedData.nativeStorageStub), + }; + }); + + t.afterEach(async () => { + await afterEach(mockedData); + }); + + t.it(`${platform} should automatically initialise the SDK after creation`, async () => { + sdk = await createSDK(testOptions); + t.expect(mockedData.initSpy).toHaveBeenCalled(); + }); + + t.it(`${platform} should enable analytics by default if platform is not nodejs`, async () => { + sdk = await createSDK(testOptions); + t.expect(mockedData.setupAnalyticsSpy).toHaveBeenCalled(); + + if (platform !== 'web') { + t.expect(analytics.enable).not.toHaveBeenCalled(); + t.expect(analytics.track).not.toHaveBeenCalled(); + } else { + t.expect(analytics.enable).toHaveBeenCalled(); + t.expect(analytics.track).toHaveBeenCalledWith('sdk_initialized', {}); + } + }); + + t.it(`${platform} should NOT call analytics.enable if analytics is DISABLED`, async () => { + (testOptions.analytics as any).enabled = false; + sdk = await createSDK(testOptions); + t.expect(sdk).toBeDefined(); + t.expect(mockedData.initSpy).toHaveBeenCalled(); + t.expect(mockedData.setupAnalyticsSpy).toHaveBeenCalled(); + t.expect(analytics.enable).not.toHaveBeenCalled(); + t.expect(analytics.track).not.toHaveBeenCalled(); + }); + + t.it(`${platform} should call init and setupAnalytics with logger configuration`, async () => { + const mockLogger = (loggerModule as any).__mockLogger; + + sdk = await createSDK(testOptions); + t.expect(sdk).toBeDefined(); + t.expect(mockLogger).not.toHaveBeenCalled(); + + t.expect(mockedData.initSpy).toHaveBeenCalled(); + t.expect(mockedData.setupAnalyticsSpy).toHaveBeenCalled(); + t.expect(sdk.state).toBe('loaded'); + t.expect(loggerModule.enableDebug).toHaveBeenCalledWith('metamask-sdk:core'); + }); + + t.it(`${platform} should properly initialize if existing session transport if found during init`, async () => { + // Set the transport type as a string in storage (this is how it's stored) + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + mockedData.mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + + sdk = await createSDK(testOptions); + + t.expect(sdk.state).toBe('loaded'); + t.expect(sdk.transport).toBeDefined(); + t.expect(sdk.provider).toBeDefined(); + t.expect(sdk.storage).toBeDefined(); + + // Verify that the session was retrieved during initialization + t.expect(mockedData.mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockedData.mockTransport.isConnected).toHaveBeenCalled(); + t.expect(mockedData.mockTransport.connect).toHaveBeenCalled(); + }); + + t.it(`${platform} should emit sessionChanged event when existing valid session is found during init`, async () => { + // Set the transport type as a string in storage (this is how it's stored) + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + // Spy on the MultichainSDK's emit method before creating the SDK + const emitSpy = t.vi.spyOn(MultichainSDK.prototype, 'emit'); + + sdk = await createSDK(testOptions); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.state).toBe('loaded'); + + // Check that sessionChanged event was emitted with the expected session data during initialization + t.expect(emitSpy).toHaveBeenCalledWith('sessionChanged', mockSessionData); + + // Restore the spy + emitSpy.mockRestore(); + }); + + t.it(`${platform} Should gracefully handle init errors by just logging them and return non initialized sdk`, async () => { + const testError = new Error('Test error'); + + mockedData.setupAnalyticsSpy.mockImplementation(() => { + throw testError; + }); + + sdk = await createSDK(testOptions); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.state).toBe('pending'); + + // Access the mock logger from the module + const mockLogger = (loggerModule as any).__mockLogger; + + // Verify that the logger was called with the error + t.expect(mockLogger).toHaveBeenCalledWith('MetaMaskSDK error during initialization', testError); + }); + }); +} + +const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; + +const baseTestOptions = { options: { dapp: exampleDapp }, tests: testSuite }; + +createTest({ + ...baseTestOptions, + platform: 'node', + createSDK: createMetamaskSDKNode, + setupMocks: (nativeStorageStub) => { + const memfs = new Map(); + t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString())); + t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data)); + t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString())); + t.vi.spyOn(nodeStorage, 'StoreAdapterNode').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'rn', + createSDK: createMetamaskSDKRN, + setupMocks: (nativeStorageStub) => { + t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key)); + t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)); + t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.deleteItem(key)); + t.vi.spyOn(rnStorage, 'StoreAdapterRN').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'web', + createSDK: createMetamaskSDKWeb, + setupMocks: (nativeStorageStub) => { + const dom = new Page('

Hello world

', { + url: 'https://dapp.io/', + }); + const globalStub = { + ...dom.window, + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + localStorage: nativeStorageStub, + }; + t.vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', + }); + t.vi.stubGlobal('window', globalStub); + t.vi.stubGlobal('location', dom.window.location); + t.vi.stubGlobal('document', dom.window.document); + t.vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); diff --git a/packages/sdk-multichain/src/invoke.test.ts b/packages/sdk-multichain/src/invoke.test.ts new file mode 100644 index 000000000..b677e2602 --- /dev/null +++ b/packages/sdk-multichain/src/invoke.test.ts @@ -0,0 +1,189 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import fs from 'node:fs'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { JSDOM as Page } from 'jsdom'; +import * as t from 'vitest'; +import { vi } from 'vitest'; +import type { InvokeMethodOptions, MultiChainFNOptions, MultichainCore } from './domain'; +// Carefull, order of import matters to keep mocks working +import { createTest, type MockedData, type TestSuiteOptions } from './fixtures.test'; +import { createMetamaskSDK as createMetamaskSDKWeb } from './index.browser'; +import { createMetamaskSDK as createMetamaskSDKRN } from './index.native'; +import { createMetamaskSDK as createMetamaskSDKNode } from './index.node'; +import { Store } from './store'; +import * as nodeStorage from './store/adapters/node'; +import * as rnStorage from './store/adapters/rn'; +import * as webStorage from './store/adapters/web'; + +vi.mock('cross-fetch', () => { + const mockFetch = vi.fn(); + return { + default: mockFetch, + __mockFetch: mockFetch, + }; +}); + +function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { + const { beforeEach, afterEach } = options; + const originalSdkOptions = sdkOptions; + let sdk: MultichainCore; + + t.describe(`${platform} tests`, () => { + let mockedData: MockedData; + let testOptions: T; + const transportString = platform === 'web' ? 'browser' : 'mwp'; + + t.beforeEach(async () => { + mockedData = await beforeEach(); + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + // Set the transport type as a string in storage (this is how it's stored) + testOptions = { + ...originalSdkOptions, + analytics: { + ...originalSdkOptions.analytics, + enabled: platform !== 'node', + integrationType: 'test', + }, + storage: new Store(mockedData.nativeStorageStub), + }; + }); + + t.afterEach(async () => { + await afterEach(mockedData); + }); + + t.it(`${platform} should invoke method successfully from provider`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + // Mock the RPCClient response + const mockResponse = { result: 'success' }; + (multichainModule as any).__mockInvokeResponse.mockResolvedValue(mockResponse); + + sdk = await createSDK(testOptions); + + const providerInvokeMethodSpy = t.vi.spyOn(sdk.provider, 'invokeMethod'); + + const options = { + scope: 'eip155:1', + request: { method: 'eth_accounts', params: [] }, + } as InvokeMethodOptions; + const result = await sdk.invokeMethod(options); + t.expect(providerInvokeMethodSpy).toHaveBeenCalledWith(options); + t.expect(result).toEqual(mockResponse); + }); + + t.it(`${platform} should invoke readonly method successfully from client if infuraAPIKey exists`, async () => { + // Mock the RPCClient response + const mockJsonResponse = { result: 'success' }; + const fetchModule = await import('cross-fetch'); + const mockFetch = (fetchModule as any).__mockFetch; + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue({ result: mockJsonResponse }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + sdk = await createSDK({ + ...testOptions, + api: { + ...testOptions.api, + infuraAPIKey: '1234567890', + }, + }); + + const providerInvokeMethodSpy = t.vi.spyOn(sdk.provider, 'invokeMethod'); + const options = { + scope: 'eip155:1', + request: { method: 'eth_accounts', params: [] }, + } as InvokeMethodOptions; + const result = await sdk.invokeMethod(options); + + t.expect(providerInvokeMethodSpy).not.toHaveBeenCalled(); + t.expect(mockFetch).toHaveBeenCalled(); + t.expect(result).toEqual(mockJsonResponse); + }); + + t.it(`${platform} should handle invoke method errors`, async () => { + const multichainModule = await import('@metamask/multichain-api-client'); + const mockError = new Error('Failed to invoke method'); + (multichainModule as any).__mockInvokeResponse.mockRejectedValue(mockError); + sdk = await createSDK(testOptions); + const options = { + scope: 'eip155:1', + request: { + method: 'eth_accounts', + params: [], + }, + } as InvokeMethodOptions; + await t.expect(sdk.invokeMethod(options)).rejects.toThrow('Failed to invoke method'); + }); + }); +} + +const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; + +const baseTestOptions = { options: { dapp: exampleDapp }, tests: testSuite }; + +createTest({ + ...baseTestOptions, + platform: 'node', + createSDK: createMetamaskSDKNode, + setupMocks: (nativeStorageStub) => { + const memfs = new Map(); + t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString())); + t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data)); + t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString())); + t.vi.spyOn(nodeStorage, 'StoreAdapterNode').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'rn', + createSDK: createMetamaskSDKRN, + setupMocks: (nativeStorageStub) => { + t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key)); + t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)); + t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.deleteItem(key)); + t.vi.spyOn(rnStorage, 'StoreAdapterRN').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'web', + createSDK: createMetamaskSDKWeb, + setupMocks: (nativeStorageStub) => { + const dom = new Page('

Hello world

', { + url: exampleDapp.url, + }); + const globalStub = { + ...dom.window, + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + localStorage: nativeStorageStub, + ethereum: { + isMetaMask: true, + }, + }; + t.vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', + language: 'en-US', + }); + t.vi.stubGlobal('window', globalStub); + t.vi.stubGlobal('location', dom.window.location); + t.vi.stubGlobal('document', dom.window.document); + t.vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + t.vi.stubGlobal('requestAnimationFrame', t.vi.fn()); + t.vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index e66e2b54d..a5090af11 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -1,75 +1,108 @@ -import { getDefaultTransport, getMultichainClient, type MultichainApiClient, type SessionData } from '@metamask/multichain-api-client'; +import { type CreateSessionParams, getDefaultTransport, getMultichainClient, type MultichainApiClient, type SessionData, type Transport } from '@metamask/multichain-api-client'; import { analytics } from '@metamask/sdk-analytics'; -import { type CaipAccountId, parseCaipAccountId, parseCaipChainId } from '@metamask/utils'; +import type { CaipAccountId, Json } from '@metamask/utils'; import packageJson from '../../package.json'; -import type { MultichainSDKConstructor, MultichainSDKOptions, NotificationCallback, Scope, RPCAPI, InvokeMethodOptions } from '../domain'; -import { EventEmitter } from '../domain/events'; -import type { SDKEvents } from '../domain/events/types/sdk'; +import { type InvokeMethodOptions, type ModalFactoryConnectOptions, type MultichainOptions, type RPCAPI, type Scope, TransportType } from '../domain'; import { createLogger, enableDebug, isEnabled as isLoggerEnabled } from '../domain/logger'; -import type { MultichainSDKBase } from '../domain/multichain'; +import { MultichainCore, type SDKState } from '../domain/multichain'; import { getPlatformType, PlatformType } from '../domain/platform'; -import type { StoreClient } from '../domain/store/client'; -import { getAnonId, getDappId, getVersion, setupDappMetadata, setupInfuraProvider } from '../utis'; -import { RPCClient } from '../utis/rpc/client'; +import { MWPClientTransport } from './mwp'; +import { RPCClient } from './rpc/client'; +import { addValidAccounts, getDappId, getOptionalScopes, getValidAccounts, getVersion, setupDappMetadata, setupInfuraProvider } from './utils'; //ENFORCE NAMESPACE THAT CAN BE DISABLED const logger = createLogger('metamask-sdk:core'); -type OptionalScopes = Record; +let __provider: MultichainApiClient | undefined; +let __transport: Transport | undefined; -export class MultichainSDK extends EventEmitter implements MultichainSDKBase { - private provider!: MultichainApiClient; - private readonly options: MultichainSDKConstructor; - public readonly storage: StoreClient; - private readonly rpcClient: RPCClient; - public isInitialized: boolean = false; - public session: SessionData | undefined; +export class MultichainSDK extends MultichainCore { + public state: SDKState; + private listeners: (() => void)[] = []; - private constructor(options: MultichainSDKConstructor) { - super(); + /** + * Static method to reset global state - useful for testing + * @internal + */ + static resetGlobals() { + __provider = undefined; + __transport = undefined; + } - const withInfuraRPCMethods = setupInfuraProvider(options); - const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); - this.options = withDappMetadata; - this.storage = options.storage; + private get client() { const platformType = getPlatformType(); - const sdkInfo = `Sdk/Javascript SdkVersion/${packageJson.version} Platform/${platformType} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${ - this.options.dapp.name - }`; - this.provider = getMultichainClient({ transport: this.transport }); - this.rpcClient = new RPCClient(this.provider, this.options.api, sdkInfo); + const sdkInfo = `Sdk/Javascript SdkVersion/${packageJson.version} Platform/${platformType} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${this.options.dapp.name}`; + return new RPCClient(this.provider, this.options.api, sdkInfo); } - private get transport() { - const platformType = getPlatformType(); - if (platformType === PlatformType.DesktopWeb || platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.MobileWeb) { - //Direct support for web and externally connectable - const transport = getDefaultTransport(this.options.transport); - return transport; + get provider() { + if (!__provider) { + throw new Error('Provider not initialized, establish connection first'); } - //Mobile wallet protocol support - throw new Error('Not implemented'); + return __provider; } - static async create(options: MultichainSDKOptions) { - const instance = new MultichainSDK(options); - const isEnabled = await isLoggerEnabled('metamask-sdk:core', instance.storage); - if (isEnabled) { - enableDebug('metamask-sdk:core'); + get transport() { + if (!__transport) { + throw new Error('Transport not initialized, establish connection first'); } + return __transport; + } + + private async getCurrentSession(): Promise { try { - await instance.init(); - if (typeof window !== 'undefined') { - window.mmsdk = instance; + //TODO: We should report to the multichain api team that when there's no extension installed + // getSession timeouts and should be just undefined + // Thats why we need this function, to compensate that + let validSession: SessionData | undefined; + const session = await this.provider.getSession(); + if (Object.keys(session?.sessionScopes ?? {}).length > 0) { + validSession = session; } + return validSession; } catch (err) { - logger('MetaMaskSDK error during initialization', err); + logger('MetaMaskSDK error during getCurrentSession', err); + return undefined; } + } + + get storage() { + return this.options.storage; + } + + private constructor(options: MultichainOptions) { + const withInfuraRPCMethods = setupInfuraProvider(options); + const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); + const allOptions = { + ...withDappMetadata, + ui: { + ...withDappMetadata.ui, + preferExtension: withDappMetadata.ui.preferExtension ?? true, + preferDesktop: withDappMetadata.ui.preferDesktop ?? false, + headless: withDappMetadata.ui.headless ?? false, + }, + analytics: { + ...(options.analytics ?? {}), + enabled: options.analytics?.enabled !== undefined ? options.analytics.enabled : true, + integrationType: 'unknown', + }, + }; + super(allOptions); + this.state = 'pending'; + } + + static async create(options: MultichainOptions) { + const instance = new MultichainSDK(options); + const isEnabled = await isLoggerEnabled('metamask-sdk:core', instance.options.storage); + if (isEnabled) { + enableDebug('metamask-sdk:core'); + } + await instance.init(); return instance; } private async setupAnalytics() { - if (!this.options.analytics.enabled) { + if (!this.options.analytics?.enabled) { return; } @@ -84,7 +117,7 @@ export class MultichainSDK extends EventEmitter implements Multichain const version = getVersion(); const dappId = getDappId(this.options.dapp); - const anonId = await getAnonId(this.storage); + const anonId = await this.storage.getAnonId(); const integrationType = this.options.analytics.integrationType; analytics.setGlobalProperty('sdk_version', version); @@ -96,84 +129,198 @@ export class MultichainSDK extends EventEmitter implements Multichain analytics.track('sdk_initialized', {}); } - async init() { - if (typeof window !== 'undefined' && window.mmsdk?.isInitialized) { - logger('MetaMaskSDK: init already initialized'); - } - await this.setupAnalytics(); - this.session = await this.provider.getSession(); - this.isInitialized = true; - } - - async connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise { - if (!this.transport.isConnected) { - await this.transport.connect(); - } - - const optionalScopes = scopes.reduce( - (prev, scope) => ({ - ...prev, - [scope]: { - methods: [], - notifications: [], - accounts: [], - }, - }), - {}, - ); - - const validAccounts = caipAccountIds.reduce[]>((caipAccounts, caipAccountId) => { - try { - return [...caipAccounts, parseCaipAccountId(caipAccountId)]; - } catch (err) { - const stringifiedAccountId = JSON.stringify(caipAccountId); - console.error(`Invalid CAIP account ID: ${stringifiedAccountId}`, err); - return caipAccounts; + //TODO: Find better ways to type this, if its worth or just use unknown + // biome-ignore lint/suspicious/noExplicitAny: Figure out later + private async onTransportNotification(data: any) { + if (data.method === 'session_changed') { + const session = data.params.session; + //TODO: We also should report this as an issue, sessions with no sessionScopes should be undefined, is there any reason + //why the object comes empty? + if (Object.keys(session?.sessionScopes ?? {}).length > 0) { + this.emit('sessionChanged', session); + } else { + this.emit('sessionChanged', undefined); } - }, []); - - for (const account of validAccounts) { - for (const scopeKey of Object.keys(optionalScopes)) { - const scope = scopeKey as Scope; - const scopeDetails = parseCaipChainId(scope); - if (scopeDetails.namespace === account.chain.namespace && scopeDetails.reference === account.chain.reference) { - const scopeData = optionalScopes[scope]; - if (scopeData) { - scopeData.accounts.push(account.address as CaipAccountId); - } - } + } + } + + private async initialTransport() { + const transportType = await this.storage.getTransport(); + if (transportType) { + if (transportType === TransportType.Browser) { + return getDefaultTransport(this.options.transport); + } else if (transportType === TransportType.MPW) { + return MWPClientTransport; + } else { + await this.storage.removeTransport(); } } + return undefined; + } - const session = await this.provider.getSession(); - if (session) { - const existingScopes = Object.keys(session.sessionScopes).sort(); - const scopesMatch = existingScopes.every((scope) => scopes.includes(scope as Scope)) && scopes.every((scope) => existingScopes.includes(scope)); + private async setupTransport() { + const initialTransport = await this.initialTransport(); + if (initialTransport) { + __transport = initialTransport; + } + if (__transport) { + const listener = __transport.onNotification(this.onTransportNotification); + if (!__transport.isConnected()) { + await __transport.connect(); + } + __provider = getMultichainClient({ transport: __transport }); + this.listeners.push(listener); + const session = await this.getCurrentSession(); + if (Object.keys(session?.sessionScopes ?? {}).length > 0) { + this.emit('sessionChanged', session); + } + } + } - if (scopesMatch) { - // Existing session has exactly the same scopes as requested, reuse it - return session; + async init() { + try { + if (typeof window !== 'undefined' && window.mmsdk?.isInitialized) { + logger('MetaMaskSDK: init already initialized'); + } else { + await this.setupAnalytics(); + await this.setupTransport(); + this.state = 'loaded'; + if (typeof window !== 'undefined') { + window.mmsdk = this; + } } - // Scopes don't match (different set), revoke the session and create new one + } catch (error) { + logger('MetaMaskSDK error during initialization', error); + } + } + + private get hasExtension() { + if (typeof window !== 'undefined') { + return window.ethereum?.isMetaMask ?? false; + } + return false; + } + + private async onConnectionSuccess(type: TransportType, transport: Transport, params: ModalFactoryConnectOptions) { + if (!transport.isConnected()) { + await transport.connect(); + } + + __transport = transport; + __provider = getMultichainClient({ transport }); + + await this.storage.setTransport(type); + + const session = await this.getCurrentSession(); + const currentScopes = Object.keys(session?.sessionScopes ?? {}) as Scope[]; + const proposedScopes = params.scopes; + + const isSameScopes = currentScopes.every((scope) => proposedScopes.includes(scope)) && proposedScopes.every((scope) => currentScopes.includes(scope)); + + if (isSameScopes) { + this.emit('sessionChanged', session); + return; + } + + if (session) { await this.provider.revokeSession(); } - this.session = await this.provider.createSession({ optionalScopes }); - return this.session; + + const { scopes, caipAccountIds } = params; + const optionalScopes = addValidAccounts(getOptionalScopes(scopes), getValidAccounts(caipAccountIds)); + const sessionRequest: CreateSessionParams = { optionalScopes }; + + const newSession = await this.provider.createSession(sessionRequest); + this.emit('sessionChanged', newSession); } - async disconnect(): Promise { - return this.transport.disconnect(); + private getTransportForPlatformType(platformType: PlatformType) { + if (__transport) { + return __transport; + } + if (platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.DesktopWeb || platformType === PlatformType.MobileWeb) { + return getDefaultTransport(this.options.transport); + } + return MWPClientTransport; } - onNotification(listener: NotificationCallback) { - return this.provider.onNotification(listener); + async connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise { + const { + ui: { factory, ...uiProperties }, + } = this.options; + const { preferExtension = false, preferDesktop = false, headless: _headless = false } = uiProperties; + const platformType = getPlatformType(); + const transport = await this.getTransportForPlatformType(platformType); + const isWeb = platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.DesktopWeb || platformType === PlatformType.MobileWeb; + const existingSession = await this.getCurrentSession(); + + if (isWeb) { + if (existingSession) { + return this.onConnectionSuccess(TransportType.Browser, transport, { + scopes, + caipAccountIds, + }); + } + + if (this.hasExtension && preferExtension) { + return this.onConnectionSuccess(TransportType.Browser, transport, { + scopes, + caipAccountIds, + }); + } + + const link = this.options.dapp.url ?? this.options.dapp.name ?? 'dummy'; + if (!this.hasExtension) { + if (preferExtension) { + // render install modal with extension tab selected + return factory.renderInstallModal(link, false); + } + // Doesn't have extension so we show install modal in the preferDesktop value + return factory.renderInstallModal(link, preferDesktop); + } + + if (!preferExtension) { + // Has extension but we don't automatically chooose extension so we should show + return factory.renderSelectModal(link, true, async () => { + //This callback is after the user clicked extension in the select tab + return this.onConnectionSuccess(TransportType.Browser, transport, { + scopes, + caipAccountIds, + }); + }); + } + + //We have extension and extension is the prefferred + return this.onConnectionSuccess(TransportType.Browser, transport, { + scopes, + caipAccountIds, + }); + } else if (platformType === PlatformType.NonBrowser) { + return this.onConnectionSuccess(TransportType.MPW, transport, { + scopes, + caipAccountIds, + }); + } + + throw new Error('Not implemented'); } - async revokeSession() { - return this.provider.revokeSession(); + async disconnect(): Promise { + await __transport?.disconnect(); + await __provider?.revokeSession(); + + this.listeners.forEach((listener) => listener()); + + __transport = undefined; + __provider = undefined; + + this.emit('sessionChanged', undefined); + this.listeners = []; + + await this.storage.removeTransport(); } - async invokeMethod(options: InvokeMethodOptions) { - return this.rpcClient.invokeMethod(options); + async invokeMethod(options: InvokeMethodOptions): Promise { + return this.client.invokeMethod(options); } } diff --git a/packages/sdk-multichain/src/multichain/mwp/index.ts b/packages/sdk-multichain/src/multichain/mwp/index.ts new file mode 100644 index 000000000..1daafd89e --- /dev/null +++ b/packages/sdk-multichain/src/multichain/mwp/index.ts @@ -0,0 +1,12 @@ +import type { Transport, TransportRequest, TransportResponse } from '@metamask/multichain-api-client'; + +/** + * Wrapper for the integration with the Mobile Wallet Protocol and the Dapp Client + */ +export const MWPClientTransport: Transport = { + connect: () => Promise.resolve(), + disconnect: () => Promise.resolve(), + isConnected: () => true, + request: (_request: TRequest) => Promise.resolve({} as TResponse), + onNotification: (_callback: (data: unknown) => void) => () => {}, +}; diff --git a/packages/sdk-multichain/src/utis/rpc/client.test.ts b/packages/sdk-multichain/src/multichain/rpc/client.test.ts similarity index 97% rename from packages/sdk-multichain/src/utis/rpc/client.test.ts rename to packages/sdk-multichain/src/multichain/rpc/client.test.ts index db530db3f..b83a1a726 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.test.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.test.ts @@ -1,8 +1,10 @@ +/** 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 { type InvokeMethodOptions, - type MultichainSDKConstructor, + type MultichainOptions, RPC_METHODS, RPCHttpErr, RPCInvokeMethodErr, @@ -31,7 +33,7 @@ vi.mock('./client', async () => { t.describe('RPCClient', () => { let mockProvider: any; - let mockConfig: MultichainSDKConstructor['api']; + let mockConfig: MultichainOptions['api']; let sdkInfo: string; let rpcClient: RPCClient; let rpcClientModule: typeof RPCClient; diff --git a/packages/sdk-multichain/src/utis/rpc/client.ts b/packages/sdk-multichain/src/multichain/rpc/client.ts similarity index 93% rename from packages/sdk-multichain/src/utis/rpc/client.ts rename to packages/sdk-multichain/src/multichain/rpc/client.ts index 5c089283b..877e8dfe6 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.ts +++ b/packages/sdk-multichain/src/multichain/rpc/client.ts @@ -1,20 +1,20 @@ +import type { InvokeMethodParams, MultichainApiClient } from '@metamask/multichain-api-client'; import type { Json } from '@metamask/utils'; import fetch from 'cross-fetch'; -import type { InvokeMethodParams, MultichainApiClient } from '@metamask/multichain-api-client'; import { + getInfuraRpcUrls, type InvokeMethodOptions, - type MultichainSDKConstructor, + METHODS_TO_REDIRECT, + type MultichainOptions, type RPC_URLS_MAP, type RPCAPI, - type RPCResponse, - type Scope, - METHODS_TO_REDIRECT, - RPCReadonlyResponseErr, RPCHttpErr, - RPCReadonlyRequestErr, RPCInvokeMethodErr, - getInfuraRpcUrls, + RPCReadonlyRequestErr, + RPCReadonlyResponseErr, + type RPCResponse, + type Scope, } from '../../domain'; let rpcId = 1; @@ -27,7 +27,7 @@ export function getNextRpcId() { export class RPCClient { constructor( private readonly provider: MultichainApiClient, - private readonly config: MultichainSDKConstructor['api'], + private readonly config: MultichainOptions['api'], private readonly sdkInfo: string, ) {} @@ -90,7 +90,7 @@ export class RPCClient { const { request } = options; const { config } = this; const { infuraAPIKey, readonlyRPCMap: readonlyRPCMapConfig } = config ?? {}; - let readonlyRPCMap: RPC_URLS_MAP = {}; + let readonlyRPCMap: RPC_URLS_MAP = readonlyRPCMapConfig ?? {}; if (infuraAPIKey) { const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); if (readonlyRPCMapConfig) { @@ -101,8 +101,6 @@ export class RPCClient { } else { readonlyRPCMap = urlsWithToken; } - } else { - readonlyRPCMap = readonlyRPCMapConfig ?? {}; } const rpcEndpoint = readonlyRPCMap[options.scope]; const isReadOnlyMethod = !METHODS_TO_REDIRECT[request.method]; diff --git a/packages/sdk-multichain/src/multichain/utils/index.test.ts b/packages/sdk-multichain/src/multichain/utils/index.test.ts new file mode 100644 index 000000000..10c749bc7 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/utils/index.test.ts @@ -0,0 +1,280 @@ +/** 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, isMetamaskExtensionInstalled, 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: 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 () => { + const mockGetPlatformType = t.vi.mocked(getPlatformType); + mockGetPlatformType.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('isMetamaskExtensionInstalled', () => { + t.it('should return true if MetaMask is installed', () => { + t.vi.stubGlobal('window', { + ethereum: { + isMetaMask: true, + }, + }); + t.expect(isMetamaskExtensionInstalled()).toBe(true); + }); + + t.it('should return false if MetaMask is not installed', () => { + t.vi.stubGlobal('window', undefined); + t.expect(isMetamaskExtensionInstalled()).toBe(false); + }); + }); +}); diff --git a/packages/sdk-multichain/src/multichain/utils/index.ts b/packages/sdk-multichain/src/multichain/utils/index.ts new file mode 100644 index 000000000..411b8954c --- /dev/null +++ b/packages/sdk-multichain/src/multichain/utils/index.ts @@ -0,0 +1,187 @@ +import { type CaipAccountId, type CaipChainId, parseCaipAccountId, parseCaipChainId } from '@metamask/utils'; +import packageJson from '../../../package.json'; +import { type DappSettings, getInfuraRpcUrls, getPlatformType, type MultichainOptions, PlatformType, type Scope, type SessionData } from '../../domain'; + +export type OptionalScopes = Record; + +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 function getVersion() { + return packageJson.version; +} + +export function getOptionalScopes(scopes: Scope[]) { + return scopes.reduce( + (prev, scope) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: Needed + ...prev, + [scope]: { + methods: [], + notifications: [], + accounts: [], + }, + }), + {}, + ); +} + +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; +} + +export function getValidAccounts(caipAccountIds: CaipAccountId[]) { + return caipAccountIds.reduce[]>((caipAccounts, caipAccountId) => { + try { + // biome-ignore lint/performance/noAccumulatingSpread: Needed + return [...caipAccounts, parseCaipAccountId(caipAccountId)]; + } catch (err) { + const stringifiedAccountId = JSON.stringify(caipAccountId); + console.error(`Invalid CAIP account ID: ${stringifiedAccountId}`, err); + return caipAccounts; + } + }, []); +} + +/** + * Adds valid accounts to their corresponding scopes based on chain namespace and reference. + * Returns a new OptionalScopes object without modifying the input. + * + * @param optionalScopes - The scopes to add accounts to + * @param validAccounts - Array of parsed valid accounts + * @returns A new OptionalScopes object with accounts added to matching scopes + */ +export function addValidAccounts(optionalScopes: OptionalScopes, validAccounts: ReturnType): OptionalScopes { + if (!optionalScopes || !validAccounts?.length) { + return optionalScopes; + } + + const result: OptionalScopes = Object.fromEntries( + Object.entries(optionalScopes).map(([scope, scopeData]) => [ + scope, + { + methods: [...(scopeData?.methods ?? [])], + notifications: [...(scopeData?.notifications ?? [])], + accounts: [...(scopeData?.accounts ?? [])], + }, + ]), + ); + + // Group accounts by their chain identifier for efficient lookup + const accountsByChain = new Map(); + for (const account of validAccounts) { + const chainKey = `${account.chain.namespace}:${account.chain.reference}`; + const accountId = `${account.chainId}:${account.address}` as CaipAccountId; + + if (!accountsByChain.has(chainKey)) { + accountsByChain.set(chainKey, []); + } + accountsByChain.get(chainKey)?.push(accountId); + } + + // Add accounts to matching scopes + for (const [scopeKey, scopeData] of Object.entries(result)) { + if (!scopeData?.accounts) { + continue; + } + + try { + const scope = scopeKey as CaipChainId; + const scopeDetails = parseCaipChainId(scope); + const chainKey = `${scopeDetails.namespace}:${scopeDetails.reference}`; + + const matchingAccounts = accountsByChain.get(chainKey); + if (matchingAccounts) { + const existingAccounts = new Set(scopeData.accounts); + const newAccounts = matchingAccounts.filter((account) => !existingAccounts.has(account)); + scopeData.accounts.push(...newAccounts); + } + } catch (error) { + console.error(`Invalid scope format: ${scopeKey}`, error); + } + } + + return result; +} diff --git a/packages/sdk-multichain/src/session.test.ts b/packages/sdk-multichain/src/session.test.ts new file mode 100644 index 000000000..f348cea6c --- /dev/null +++ b/packages/sdk-multichain/src/session.test.ts @@ -0,0 +1,201 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import fs from 'node:fs'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { JSDOM as Page } from 'jsdom'; +import * as t from 'vitest'; +import type { MultiChainFNOptions, MultichainCore, Scope, SessionData } from './domain'; +// Carefull, order of import matters to keep mocks working +import { createTest, type MockedData, mockSessionData, type TestSuiteOptions } from './fixtures.test'; +import { createMetamaskSDK as createMetamaskSDKWeb } from './index.browser'; +import { createMetamaskSDK as createMetamaskSDKRN } from './index.native'; +import { createMetamaskSDK as createMetamaskSDKNode } from './index.node'; +import * as nodeStorage from './store/adapters/node'; +import * as rnStorage from './store/adapters/rn'; +import * as webStorage from './store/adapters/web'; + +import * as loggerModule from './domain/logger'; +import { Store } from './store'; + +function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { + const { beforeEach, afterEach } = options; + const originalSdkOptions = sdkOptions; + let sdk: MultichainCore; + + t.describe(`${platform} tests`, () => { + let mockedData: MockedData; + let testOptions: T; + const transportString = platform === 'web' ? 'browser' : 'mwp'; + + t.beforeEach(async () => { + mockedData = await beforeEach(); + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + + // Set the transport type as a string in storage (this is how it's stored) + testOptions = { + ...originalSdkOptions, + analytics: { + ...originalSdkOptions.analytics, + enabled: platform !== 'node', + integrationType: 'test', + }, + storage: new Store(mockedData.nativeStorageStub), + }; + }); + + t.afterEach(async () => { + await afterEach(mockedData); + }); + + t.it(`${platform} should handle session upgrades`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockedData.mockTransport.connect.mockResolvedValue(); + mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + + sdk = await createSDK(testOptions); + + t.expect(sdk.state).toBe('loaded'); + t.expect(sdk.provider).toBeDefined(); + t.expect(sdk.transport).toBeDefined(); + t.expect(sdk.storage).toBeDefined(); + t.expect(mockedData.mockTransport.connect).toHaveBeenCalled(); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockedData.emitSpy).toHaveBeenCalledWith('sessionChanged', mockSessionData); + + const mockedSessionUpgradeData: SessionData = { + ...mockSessionData, + sessionScopes: { + ...mockSessionData.sessionScopes, + 'eip155:137': { + methods: [], + notifications: [], + accounts: ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'], + }, + }, + }; + const scopes = ['eip155:1', 'eip155:137'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678', 'eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; + // Mock createSession to return the upgraded session data + mockMultichainClient.createSession.mockResolvedValue(mockedSessionUpgradeData); + + await sdk.connect(scopes, caipAccountIds); + + t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: mockedSessionUpgradeData.sessionScopes, + }); + // sessionChanged should be emitted with the full session data returned from createSession, not just the scopes + t.expect(mockedData.emitSpy).toHaveBeenCalledWith('sessionChanged', mockedSessionUpgradeData); + }); + + t.it(`${platform} should handle session retrieval when no session exists`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + // Mock no session scenario + mockMultichainClient.getSession.mockResolvedValue(undefined); + + sdk = await createSDK(testOptions); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.state).toBe('loaded'); + t.expect(sdk.provider).toBeDefined(); + t.expect(sdk.transport).toBeDefined(); + t.expect(sdk.storage).toBeDefined(); + t.expect(sdk.state).toBe('loaded'); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + }); + + t.it(`${platform} should handle provider errors during session retrieval`, async () => { + // Get mocks from the module mock + const multichainModule = await import('@metamask/multichain-api-client'); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + const sessionError = new Error('Session error'); + + // Clear previous calls and set up the error mock + t.vi.clearAllMocks(); + mockMultichainClient.getSession.mockRejectedValue(sessionError); + + sdk = await createSDK(testOptions); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.state === 'loaded').toBe(true); + + // Access the mock logger from the module + const mockLogger = (loggerModule as any).__mockLogger; + + // Verify that the logger was called with the error + t.expect(mockLogger).toHaveBeenCalledWith('MetaMaskSDK error during getCurrentSession', sessionError); + }); + }); +} + +const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; + +const baseTestOptions = { options: { dapp: exampleDapp }, tests: testSuite }; + +createTest({ + ...baseTestOptions, + platform: 'node', + createSDK: createMetamaskSDKNode, + setupMocks: (nativeStorageStub) => { + const memfs = new Map(); + t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString())); + t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data)); + t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString())); + t.vi.spyOn(nodeStorage, 'StoreAdapterNode').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'rn', + createSDK: createMetamaskSDKRN, + setupMocks: (nativeStorageStub) => { + t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key)); + t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)); + t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.deleteItem(key)); + t.vi.spyOn(rnStorage, 'StoreAdapterRN').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); + +createTest({ + ...baseTestOptions, + platform: 'web', + createSDK: createMetamaskSDKWeb, + setupMocks: (nativeStorageStub) => { + const dom = new Page('

Hello world

', { + url: exampleDapp.url, + }); + const globalStub = { + ...dom.window, + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + localStorage: nativeStorageStub, + ethereum: { + isMetaMask: true, + }, + }; + t.vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', + language: 'en-US', + }); + t.vi.stubGlobal('window', globalStub); + t.vi.stubGlobal('location', dom.window.location); + t.vi.stubGlobal('document', dom.window.document); + t.vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + t.vi.stubGlobal('requestAnimationFrame', t.vi.fn()); + t.vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { + return nativeStorageStub as any; + }); + }, +}); diff --git a/packages/sdk-multichain/src/store/index.test.ts b/packages/sdk-multichain/src/store/index.test.ts index 241c5c6a2..826d5d1b0 100644 --- a/packages/sdk-multichain/src/store/index.test.ts +++ b/packages/sdk-multichain/src/store/index.test.ts @@ -1,9 +1,12 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ import fs from 'node:fs'; import path from 'node:path'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as t from 'vitest'; import type { StoreAdapter } from '../domain'; import { StorageDeleteErr, StorageGetErr, StorageSetErr } from '../domain/errors/storage'; +import { TransportType } from '../domain/multichain'; import { StoreAdapterNode } from './adapters/node'; import { StoreAdapterRN } from './adapters/rn'; import { StoreAdapterWeb } from './adapters/web'; @@ -55,9 +58,9 @@ function createStoreTests(adapterName: string, createAdapter: () => StoreAdapter t.expect(result).toBe('test-anon-id'); }); - t.it('should return null when anonymous ID does not exist', async () => { + t.it('should return a new when anonymous ID does not exist', async () => { const result = await store.getAnonId(); - t.expect(result).toBeNull(); + t.expect(result).not.toBeNull(); }); }); @@ -127,6 +130,63 @@ function createStoreTests(adapterName: string, createAdapter: () => StoreAdapter }); }); + t.describe(`${adapterName} getTransport`, () => { + t.it('should return the transport value when it exists - Browser', async () => { + await adapter.setItem('multichain-transport', 'browser'); + const result = await store.getTransport(); + t.expect(result).toBe(TransportType.Browser); + }); + + t.it('should return the transport value when it exists - MPW', async () => { + await adapter.setItem('multichain-transport', 'mwp'); + const result = await store.getTransport(); + t.expect(result).toBe(TransportType.MPW); + }); + + t.it('should return UNKNOWN for unknown transport types', async () => { + await adapter.setItem('multichain-transport', 'unknown-transport'); + const result = await store.getTransport(); + t.expect(result).toBe(TransportType.UNKNOWN); + }); + + t.it('should return null when transport does not exist', async () => { + const result = await store.getTransport(); + t.expect(result).toBeNull(); + }); + }); + + t.describe(`${adapterName} setTransport`, () => { + t.it('should set the transport successfully - Browser', async () => { + await store.setTransport(TransportType.Browser); + const result = await adapter.getItem('multichain-transport'); + t.expect(result).toBe('browser'); + }); + + t.it('should set the transport successfully - MPW', async () => { + await store.setTransport(TransportType.MPW); + const result = await adapter.getItem('multichain-transport'); + t.expect(result).toBe('mwp'); + }); + + t.it('should set the transport successfully - UNKNOWN', async () => { + await store.setTransport(TransportType.UNKNOWN); + const result = await adapter.getItem('multichain-transport'); + t.expect(result).toBe('unknown'); + }); + }); + + t.describe(`${adapterName} removeTransport`, () => { + t.it('should remove the transport successfully', async () => { + await adapter.setItem('multichain-transport', 'browser'); + const beforeRemove = await adapter.getItem('multichain-transport'); + t.expect(beforeRemove).toBe('browser'); + + await store.removeTransport(); + const afterRemove = await adapter.getItem('multichain-transport'); + t.expect(afterRemove).toBeNull(); + }); + }); + // Error handling tests t.describe(`${adapterName} error handling`, () => { t.it('should throw StorageGetErr when fetching a key fails', async () => { @@ -135,6 +195,7 @@ function createStoreTests(adapterName: string, createAdapter: () => StoreAdapter await t.expect(store.getAnonId()).rejects.toBeInstanceOf(StorageGetErr); await t.expect(store.getExtensionId()).rejects.toBeInstanceOf(StorageGetErr); await t.expect(store.getDebug()).rejects.toBeInstanceOf(StorageGetErr); + await t.expect(store.getTransport()).rejects.toBeInstanceOf(StorageGetErr); }); t.it('should throw StorageSetErr when setting a key fails', async () => { @@ -142,7 +203,7 @@ function createStoreTests(adapterName: string, createAdapter: () => StoreAdapter t.vi.spyOn(adapter, 'setItem').mockRejectedValue(new Error(errorMessage)); await t.expect(store.setAnonId('test-id')).rejects.toThrow(StorageSetErr); await t.expect(store.setExtensionId('test-id')).rejects.toThrow(StorageSetErr); - await t.expect(store.setExtensionId('test-id')).rejects.toThrow(StorageSetErr); + await t.expect(store.setTransport(TransportType.Browser)).rejects.toThrow(StorageSetErr); }); t.it('should throw StorageDeleteErr when removing a key fails', async () => { @@ -150,6 +211,7 @@ function createStoreTests(adapterName: string, createAdapter: () => StoreAdapter t.vi.spyOn(adapter, 'deleteItem').mockRejectedValue(new Error(errorMessage)); await t.expect(store.removeAnonId()).rejects.toThrow(StorageDeleteErr); await t.expect(store.removeExtensionId()).rejects.toThrow(StorageDeleteErr); + await t.expect(store.removeTransport()).rejects.toThrow(StorageDeleteErr); }); }); } diff --git a/packages/sdk-multichain/src/store/index.ts b/packages/sdk-multichain/src/store/index.ts index c76acf349..3a4fa10f0 100644 --- a/packages/sdk-multichain/src/store/index.ts +++ b/packages/sdk-multichain/src/store/index.ts @@ -1,5 +1,8 @@ -import type { StoreAdapter, StoreClient } from '../domain'; +import * as uuid from 'uuid'; + +import type { StoreAdapter, StoreClient, TransportType } from '../domain'; import { StorageDeleteErr, StorageGetErr, StorageSetErr } from '../domain/errors/storage'; +import { getTransportType } from '../domain/multichain'; export class Store implements StoreClient { readonly #adapter: StoreAdapter; @@ -8,9 +11,43 @@ export class Store implements StoreClient { this.#adapter = adapter; } - async getAnonId(): Promise { + async getTransport(): Promise { + try { + const transport = await this.#adapter.getItem('multichain-transport'); + if (!transport) { + return null; + } + return getTransportType(transport); + } catch (err) { + throw new StorageGetErr(this.#adapter.platform, 'multichain-transport', err.message); + } + } + + async setTransport(transport: TransportType): Promise { + try { + await this.#adapter.setItem('multichain-transport', transport); + } catch (err) { + throw new StorageSetErr(this.#adapter.platform, 'multichain-transport', err.message); + } + } + + async removeTransport(): Promise { + try { + await this.#adapter.deleteItem('multichain-transport'); + } catch (err) { + throw new StorageDeleteErr(this.#adapter.platform, 'multichain-transport', err.message); + } + } + + async getAnonId(): Promise { try { - return await this.#adapter.getItem('anonId'); + const anonId = await this.#adapter.getItem('anonId'); + if (anonId) { + return anonId; + } + const newAnonId = uuid.v4(); + await this.#adapter.setItem('anonId', newAnonId); + return newAnonId; } catch (err) { throw new StorageGetErr(this.#adapter.platform, 'anonId', err.message); } diff --git a/packages/sdk-multichain/src/ui/index.test.ts b/packages/sdk-multichain/src/ui/index.test.ts new file mode 100644 index 000000000..2675fbb55 --- /dev/null +++ b/packages/sdk-multichain/src/ui/index.test.ts @@ -0,0 +1,687 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import { JSDOM as Page } from 'jsdom'; +import * as t from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Modal, PlatformType } from '../domain'; +import { UIModule } from './index'; + +// Mock external dependencies +vi.mock('@metamask/onboarding', () => ({ + default: class MockMetaMaskOnboarding { + startOnboarding = vi.fn(); + }, +})); + +vi.mock('@metamask/sdk-install-modal-web/dist/loader/index.js', () => ({ + defineCustomElements: vi.fn(), +})); + +// Mock the domain functions +vi.mock('../domain', async () => { + const actual = await vi.importActual('../domain'); + return { + ...actual, + getPlatformType: vi.fn(), + getVersion: vi.fn(() => '1.0.0'), + }; +}); + +t.describe('UIModule', () => { + let mockModal: any; + let mockFactoryOptions: any; + let dom: InstanceType; + + t.beforeEach(() => { + t.vi.clearAllMocks(); + + // Create DOM environment + dom = new Page('
', { + url: 'https://test.dapp/', + }); + + // Setup global DOM environment + t.vi.stubGlobal('window', dom.window); + t.vi.stubGlobal('document', dom.window.document); + t.vi.stubGlobal('navigator', dom.window.navigator); + + // Mock rendered modal + mockModal = { + mount: t.vi.fn(), + unmount: t.vi.fn(), + sync: t.vi.fn(), + }; + + // Mock factory options with required modals + mockFactoryOptions = { + installModal: { + render: t.vi.fn().mockResolvedValue(mockModal), + }, + selectModal: { + render: t.vi.fn().mockResolvedValue(mockModal), + }, + pendingModal: { + render: t.vi.fn().mockResolvedValue(mockModal), + }, + }; + }); + + afterEach(() => { + t.vi.unstubAllGlobals(); + t.vi.restoreAllMocks(); + }); + + describe('Constructor validation', () => { + it('should throw an exception if required modals are not present', () => { + expect(() => new UIModule({} as any)).toThrow('Missing required modals: installModal, selectModal, pendingModal'); + }); + + it('should throw an exception if only some modals are missing', () => { + const partialOptions = { + installModal: mockFactoryOptions.installModal, + }; + expect(() => new UIModule(partialOptions as any)).toThrow('Missing required modals: selectModal, pendingModal'); + }); + + it('should create successfully with all required modals', () => { + expect(() => new UIModule(mockFactoryOptions)).not.toThrow(); + }); + }); + + describe('Platform detection properties', () => { + it('should correctly identify mobile platform (React Native)', async () => { + const { getPlatformType } = await import('../domain'); + t.vi.mocked(getPlatformType).mockReturnValue(PlatformType.ReactNative); + + const newUIModule = new UIModule(mockFactoryOptions); + expect(newUIModule.isMobile).toBe(true); + expect(newUIModule.isNode).toBe(false); + expect(newUIModule.isWeb).toBe(false); + }); + + it('should correctly identify Node.js platform', async () => { + const { getPlatformType } = await import('../domain'); + t.vi.mocked(getPlatformType).mockReturnValue(PlatformType.NonBrowser); + + const newUIModule = new UIModule(mockFactoryOptions); + expect(newUIModule.isMobile).toBe(false); + expect(newUIModule.isNode).toBe(true); + expect(newUIModule.isWeb).toBe(false); + }); + + it('should correctly identify web platforms', async () => { + const webPlatforms = [PlatformType.DesktopWeb, PlatformType.MetaMaskMobileWebview, PlatformType.MobileWeb]; + + for (const platform of webPlatforms) { + const { getPlatformType } = await import('../domain'); + t.vi.mocked(getPlatformType).mockReturnValue(platform); + + const newUIModule = new UIModule(mockFactoryOptions); + expect(newUIModule.isMobile).toBe(false); + expect(newUIModule.isNode).toBe(false); + expect(newUIModule.isWeb).toBe(true); + } + }); + }); + + describe('Modal rendering', () => { + let uiModule: UIModule; + let mockContainer: HTMLDivElement; + + beforeEach(() => { + uiModule = new UIModule(mockFactoryOptions); + mockContainer = document.createElement('div'); + }); + + describe('renderInstallModal', () => { + it('should render install modal with correct props', async () => { + const link = 'https://example.com'; + const preferDesktop = true; + t.vi.spyOn(uiModule as any, 'getContainer').mockReturnValue(mockContainer); + + await uiModule.renderInstallModal(link, preferDesktop); + + expect(document.body.contains(mockContainer)).toBe(true); + + expect(mockFactoryOptions.installModal.render).toHaveBeenCalledWith({ + onAnalyticsEvent: expect.any(Function), + onClose: expect.any(Function), + metaMaskInstaller: { + startDesktopOnboarding: expect.any(Function), + }, + parentElement: mockContainer, + link, + preferDesktop, + sdkVersion: '1.0.0', + }); + expect(mockModal.mount).toHaveBeenCalled(); + }); + + it('should handle onClose callback correctly', async () => { + await uiModule.renderInstallModal('https://example.com', false); + + const renderCall = mockFactoryOptions.installModal.render.mock.calls[0][0]; + renderCall.onClose(); + + expect(mockModal.unmount).toHaveBeenCalled(); + }); + + it('should handle desktop onboarding correctly', async () => { + await uiModule.renderInstallModal('https://example.com', true); + + const renderCall = mockFactoryOptions.installModal.render.mock.calls[0][0]; + renderCall.metaMaskInstaller.startDesktopOnboarding(); + + expect(mockModal.unmount).toHaveBeenCalled(); + }); + }); + + describe('renderPendingModal', () => { + it('should render pending modal with correct props', async () => { + t.vi.spyOn(uiModule as any, 'getContainer').mockReturnValue(mockContainer); + + await uiModule.renderPendingModal(); + + expect(document.body.contains(mockContainer)).toBe(true); + expect(mockFactoryOptions.pendingModal.render).toHaveBeenCalledWith({ + onClose: expect.any(Function), + parentElement: mockContainer, + sdkVersion: '1.0.0', + displayOTP: true, + otpCode: '123456', + updateOTPValue: expect.any(Function), + }); + expect(mockModal.mount).toHaveBeenCalled(); + }); + + it('should handle onClose callback correctly', async () => { + await uiModule.renderPendingModal(); + + const renderCall = mockFactoryOptions.pendingModal.render.mock.calls[0][0]; + renderCall.onClose(); + + expect(mockModal.unmount).toHaveBeenCalled(); + }); + + it('should throw error for updateOTPValue function', async () => { + await uiModule.renderPendingModal(); + + const renderCall = mockFactoryOptions.pendingModal.render.mock.calls[0][0]; + + expect(() => renderCall.updateOTPValue('123456')).toThrow('Function not implemented.'); + }); + }); + + describe('renderSelectModal', () => { + it('should render select modal with correct props', async () => { + const link = 'https://example.com'; + const preferDesktop = false; + const mockConnect = t.vi.fn().mockResolvedValue(undefined); + t.vi.spyOn(uiModule as any, 'getContainer').mockReturnValue(mockContainer); + + await uiModule.renderSelectModal(link, preferDesktop, mockConnect); + + expect(document.body.contains(mockContainer)).toBe(true); + expect(mockFactoryOptions.selectModal.render).toHaveBeenCalledWith({ + onClose: expect.any(Function), + parentElement: mockContainer, + sdkVersion: '1.0.0', + connect: expect.any(Function), + link, + preferDesktop, + }); + expect(mockModal.mount).toHaveBeenCalled(); + }); + + it('should handle connect callback correctly', async () => { + const mockConnect = t.vi.fn().mockResolvedValue(undefined); + await uiModule.renderSelectModal('https://example.com', false, mockConnect); + + const renderCall = mockFactoryOptions.selectModal.render.mock.calls[0][0]; + await renderCall.connect(); + + expect(mockModal.unmount).toHaveBeenCalled(); + expect(mockConnect).toHaveBeenCalled(); + }); + + it('should handle onClose callback correctly', async () => { + const mockConnect = t.vi.fn(); + await uiModule.renderSelectModal('https://example.com', false, mockConnect); + + const renderCall = mockFactoryOptions.selectModal.render.mock.calls[0][0]; + renderCall.onClose(); + + expect(mockModal.unmount).toHaveBeenCalled(); + }); + }); + }); + + describe('Error handling', () => { + it('should handle modal rendering errors gracefully', async () => { + const errorOptions = { + ...mockFactoryOptions, + installModal: { + render: t.vi.fn().mockRejectedValue(new Error('Render failed')), + }, + }; + + const uiModule = new UIModule(errorOptions); + await expect(uiModule.renderInstallModal('https://example.com', false)).rejects.toThrow('Render failed'); + }); + }); + + describe('Modal lifecycle', () => { + let uiModule: UIModule; + + beforeEach(() => { + uiModule = new UIModule(mockFactoryOptions); + }); + + it('should properly unmount previous modal when rendering new one', async () => { + // Render first modal + await uiModule.renderInstallModal('https://example.com', false); + const firstModal = mockModal; + + // Mock a new modal for the second render + const secondModal = { + mount: t.vi.fn(), + unmount: t.vi.fn(), + }; + mockFactoryOptions.pendingModal.render.mockResolvedValue(secondModal); + + // Render second modal + await uiModule.renderPendingModal(); + + // First modal should be unmounted, second modal should be mounted + expect(firstModal.unmount).not.toHaveBeenCalled(); // Because we're using the same mock + expect(secondModal.mount).toHaveBeenCalled(); + }); + }); + + describe('Preload function error handling', () => { + beforeEach(async () => { + // Reset all modules to clear the singleton instance + t.vi.resetModules(); + }); + + it('should handle preload import failure gracefully and log error', async () => { + const consoleErrorSpy = t.vi.spyOn(console, 'error').mockImplementation(() => {}); + const testError = new Error('Failed to load modal customElements'); + + // Temporarily unmock the module and re-mock it to throw an error + t.vi.doUnmock('@metamask/sdk-install-modal-web/dist/loader/index.js'); + t.vi.doMock('@metamask/sdk-install-modal-web/dist/loader/index.js', async () => { + throw testError; + }); + + // Re-import to get the fresh module with cleared singleton + const { preload: freshPreload } = (await t.vi.importActual('./index')) as any; + + // Test that preload handles the error gracefully + await expect(freshPreload()).resolves.not.toThrow(); + + // Verify that the error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith('Gracefully Failed to load modal customElements:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + + // Restore the original mock + t.vi.doUnmock('@metamask/sdk-install-modal-web/dist/loader/index.js'); + t.vi.doMock('@metamask/sdk-install-modal-web/dist/loader/index.js', () => ({ + defineCustomElements: t.vi.fn(), + })); + }); + + it('should verify the exact error logging format', async () => { + const consoleErrorSpy = t.vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Mock the module to fail + t.vi.doUnmock('@metamask/sdk-install-modal-web/dist/loader/index.js'); + t.vi.doMock('@metamask/sdk-install-modal-web/dist/loader/index.js', async () => { + throw new Error('Test import failure'); + }); + + // Re-import to get the fresh module with cleared singleton + const { preload: freshPreload } = (await t.vi.importActual('./index')) as any; + + await freshPreload(); + + // Verify the exact format: first argument should be the message string, + // second argument should be an Error object + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + const [firstArg, secondArg] = consoleErrorSpy.mock.calls[0]; + + expect(firstArg).toBe('Gracefully Failed to load modal customElements:'); + expect(secondArg).toBeInstanceOf(Error); + + consoleErrorSpy.mockRestore(); + + // Restore the original mock + t.vi.doUnmock('@metamask/sdk-install-modal-web/dist/loader/index.js'); + t.vi.doMock('@metamask/sdk-install-modal-web/dist/loader/index.js', () => ({ + defineCustomElements: t.vi.fn(), + })); + }); + + it('should continue working after preload failure', async () => { + const consoleErrorSpy = t.vi.spyOn(console, 'error').mockImplementation(() => {}); + + // First, cause preload to fail + t.vi.doUnmock('@metamask/sdk-install-modal-web/dist/loader/index.js'); + t.vi.doMock('@metamask/sdk-install-modal-web/dist/loader/index.js', async () => { + throw new Error('Module load failed'); + }); + + // Re-import to get the fresh module with cleared singleton + const { UIModule: FreshUIModule } = (await t.vi.importActual('./index')) as any; + + // Test that modal rendering still works even when preload fails + const uiModule = new FreshUIModule(mockFactoryOptions); + const container = document.createElement('div'); + + await expect(uiModule.renderInstallModal(container, 'https://example.com', false)).resolves.not.toThrow(); + + // Verify the modal was rendered despite preload failure + expect(mockFactoryOptions.installModal.render).toHaveBeenCalled(); + expect(mockModal.mount).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + + // Restore the original mock + t.vi.doUnmock('@metamask/sdk-install-modal-web/dist/loader/index.js'); + t.vi.doMock('@metamask/sdk-install-modal-web/dist/loader/index.js', () => ({ + defineCustomElements: t.vi.fn(), + })); + }); + }); + + describe('updateQRCode method testing', () => { + let mockInstallModal: any; + let mockSelectModal: any; + + beforeEach(() => { + // Create mock modal elements that simulate the DOM structure + mockInstallModal = { + link: '', + querySelector: t.vi.fn(), + }; + + mockSelectModal = { + link: '', + querySelector: t.vi.fn(), + }; + }); + + describe('Generic Modal updateQRCode functionality', () => { + it('should update QR code link for install modal', async () => { + // Create a test modal instance that extends the Modal class + class TestInstallModal extends Modal { + instance: any; + + constructor() { + super(); + this.instance = { + querySelector: t.vi.fn((selector: string) => { + if (selector === 'mm-install-modal') { + return mockInstallModal; + } + return null; + }), + }; + } + + async render() { + return { + mount: t.vi.fn(), + unmount: t.vi.fn(), + sync: t.vi.fn(), + }; + } + } + + const testModal = new TestInstallModal(); + const newLink = 'https://metamask.app.link/dapp/newlink'; + + // Call updateQRCode method + testModal.updateQRCode(newLink); + + // Verify the install modal's link was updated + expect(mockInstallModal.link).toBe(newLink); + expect(testModal.instance.querySelector).toHaveBeenCalledWith('mm-install-modal'); + }); + + it('should update QR code link for select modal when install modal is not found', async () => { + class TestSelectModal extends Modal { + instance: any; + + constructor() { + super(); + this.instance = { + querySelector: t.vi.fn((selector: string) => { + if (selector === 'mm-install-modal') { + return null; // No install modal found + } + if (selector === 'mm-select-modal') { + return mockSelectModal; + } + return null; + }), + }; + } + + async render() { + return { + mount: t.vi.fn(), + unmount: t.vi.fn(), + sync: t.vi.fn(), + }; + } + } + + const testModal = new TestSelectModal(); + const newLink = 'https://metamask.app.link/dapp/selectlink'; + + // Call updateQRCode method + testModal.updateQRCode(newLink); + + // Verify the select modal's link was updated + expect(mockSelectModal.link).toBe(newLink); + expect(testModal.instance.querySelector).toHaveBeenCalledWith('mm-install-modal'); + expect(testModal.instance.querySelector).toHaveBeenCalledWith('mm-select-modal'); + }); + + it('should handle case where neither install nor select modal is found', async () => { + class TestNoModal extends Modal { + instance: any; + + constructor() { + super(); + this.instance = { + querySelector: t.vi.fn(() => null), // No modals found + }; + } + + async render() { + return { + mount: t.vi.fn(), + unmount: t.vi.fn(), + sync: t.vi.fn(), + }; + } + } + + const testModal = new TestNoModal(); + const newLink = 'https://metamask.app.link/dapp/nomodal'; + + // Call updateQRCode method - should not throw even if no modals are found + expect(() => testModal.updateQRCode(newLink)).not.toThrow(); + + // Verify both selectors were tried + expect(testModal.instance.querySelector).toHaveBeenCalledWith('mm-install-modal'); + expect(testModal.instance.querySelector).toHaveBeenCalledWith('mm-select-modal'); + }); + + it('should handle case where instance is undefined', async () => { + class TestUndefinedModal extends Modal { + instance: undefined; + + async render() { + return { + mount: t.vi.fn(), + unmount: t.vi.fn(), + sync: t.vi.fn(), + }; + } + } + + const testModal = new TestUndefinedModal(); + const newLink = 'https://metamask.app.link/dapp/undefined'; + + // Call updateQRCode method - should not throw even if instance is undefined + expect(() => testModal.updateQRCode(newLink)).not.toThrow(); + }); + }); + + describe('Integration with UIModule modals', () => { + beforeEach(() => { + // Store the original createElement method to avoid recursion + const originalCreateElement = dom.window.document.createElement.bind(dom.window.document); + + // Create proper DOM elements using the actual JSDOM implementation + const createElement = t.vi.spyOn(document, 'createElement'); + createElement.mockImplementation((tagName: string) => { + const element = originalCreateElement(tagName); + + if (tagName === 'mm-install-modal') { + // Add properties that mm-install-modal should have + (element as any).link = ''; + (element as any).sdkVersion = ''; + (element as any).preferDesktop = false; + (element as any).addEventListener = t.vi.fn(); + } + + if (tagName === 'mm-select-modal') { + // Add properties that mm-select-modal should have + (element as any).link = ''; + (element as any).sdkVersion = ''; + (element as any).preferDesktop = false; + (element as any).addEventListener = t.vi.fn(); + } + + return element; + }); + }); + + it('should support updateQRCode on install modal through UIModule', async () => { + const initialLink = 'https://metamask.app.link/initial'; + const updatedLink = 'https://metamask.app.link/updated'; + + // Create modal factory options with real modal-like behavior + const modalWithUpdateQRCode = { + instance: undefined as any, + render: t.vi.fn().mockImplementation(async (options: any) => { + const modal = document.createElement('mm-install-modal') as any; + modal.link = options.link; + modal.sdkVersion = options.sdkVersion; + modal.preferDesktop = options.preferDesktop; + + return { + mount: t.vi.fn(() => { + options.parentElement.appendChild(modal); + modalWithUpdateQRCode.instance = modal; + }), + unmount: t.vi.fn(() => { + if (options.parentElement.contains(modal)) { + options.parentElement.removeChild(modal); + } + }), + }; + }), + updateQRCode: t.vi.fn((link: string) => { + // Simulate the real updateQRCode behavior - update the modal instance directly + if (modalWithUpdateQRCode.instance) { + modalWithUpdateQRCode.instance.link = link; + } + }), + }; + + const testFactoryOptions = { + ...mockFactoryOptions, + installModal: modalWithUpdateQRCode, + }; + + const testUIModule = new UIModule(testFactoryOptions); + + // Render the modal + await testUIModule.renderInstallModal(initialLink, false); + + // Verify the modal was rendered with the initial link + expect(modalWithUpdateQRCode.render).toHaveBeenCalledWith(expect.objectContaining({ link: initialLink })); + + // Verify initial link was set + expect(modalWithUpdateQRCode.instance.link).toBe(initialLink); + + // Test updateQRCode functionality + modalWithUpdateQRCode.updateQRCode(updatedLink); + expect(modalWithUpdateQRCode.updateQRCode).toHaveBeenCalledWith(updatedLink); + expect(modalWithUpdateQRCode.instance.link).toBe(updatedLink); + }); + + it('should support updateQRCode on select modal through UIModule', async () => { + const initialLink = 'https://metamask.app.link/select-initial'; + const updatedLink = 'https://metamask.app.link/select-updated'; + + // Create modal factory options with real modal-like behavior + const modalWithUpdateQRCode = { + instance: undefined as any, + render: t.vi.fn().mockImplementation(async (options: any) => { + const modal = document.createElement('mm-select-modal') as any; + modal.link = options.link; + modal.sdkVersion = options.sdkVersion; + modal.preferDesktop = options.preferDesktop; + + return { + mount: t.vi.fn(() => { + options.parentElement.appendChild(modal); + modalWithUpdateQRCode.instance = modal; + }), + unmount: t.vi.fn(() => { + if (options.parentElement.contains(modal)) { + options.parentElement.removeChild(modal); + } + }), + }; + }), + updateQRCode: t.vi.fn((link: string) => { + // Simulate the real updateQRCode behavior - update the modal instance directly + if (modalWithUpdateQRCode.instance) { + modalWithUpdateQRCode.instance.link = link; + } + }), + }; + + const testFactoryOptions = { + ...mockFactoryOptions, + selectModal: modalWithUpdateQRCode, + }; + + const testUIModule = new UIModule(testFactoryOptions); + const mockConnect = t.vi.fn(); + + // Render the modal + await testUIModule.renderSelectModal(initialLink, false, mockConnect); + + // Verify the modal was rendered with the initial link + expect(modalWithUpdateQRCode.render).toHaveBeenCalledWith(expect.objectContaining({ link: initialLink })); + + // Verify initial link was set + expect(modalWithUpdateQRCode.instance.link).toBe(initialLink); + + // Test updateQRCode functionality + modalWithUpdateQRCode.updateQRCode(updatedLink); + expect(modalWithUpdateQRCode.updateQRCode).toHaveBeenCalledWith(updatedLink); + expect(modalWithUpdateQRCode.instance.link).toBe(updatedLink); + }); + }); + }); +}); diff --git a/packages/sdk-multichain/src/ui/index.ts b/packages/sdk-multichain/src/ui/index.ts new file mode 100644 index 000000000..ae899e2af --- /dev/null +++ b/packages/sdk-multichain/src/ui/index.ts @@ -0,0 +1,129 @@ +import MetaMaskOnboarding from '@metamask/onboarding'; +import { getPlatformType, getVersion, type InstallWidgetProps, ModalFactory, type PendingWidgetProps, PlatformType, type RenderedModal, type SelectWidgetProps } from '../domain'; + +let __instance: typeof import('@metamask/sdk-install-modal-web/dist/loader/index.js') | undefined; + +/** + * Preload install modal custom elements only once + */ +export async function preload() { + __instance ??= await import('@metamask/sdk-install-modal-web/dist/loader/index.js') + .then((loader) => { + loader.defineCustomElements(); + return Promise.resolve(loader); + }) + .catch((error) => { + console.error(`Gracefully Failed to load modal customElements:`, error); + return Promise.resolve(undefined); + }); +} + +export class UIModule extends ModalFactory { + private modal!: RenderedModal; + private readonly platform: PlatformType = getPlatformType(); + + private unload() { + if (this.modal) { + this.modal.unmount(); + } + } + + /** + * Determines if the current platform is a mobile native environment. + * Currently only includes React Native. + */ + get isMobile() { + return this.platform === PlatformType.ReactNative; + } + + /** + * Determines if the current platform is a Node.js environment. + * Used for server-side or non-browser environments. + */ + get isNode() { + return this.platform === PlatformType.NonBrowser; + } + + /** + * Determines if the current platform is a web environment. + * Includes desktop web, MetaMask mobile webview, and mobile web. + */ + get isWeb() { + return this.platform === PlatformType.DesktopWeb || this.platform === PlatformType.MetaMaskMobileWebview || this.platform === PlatformType.MobileWeb; + } + + private getContainer() { + return typeof document === 'undefined' ? undefined : document.createElement('div'); + } + + private getMountedContainer() { + if (typeof document === 'undefined') { + return undefined; + } + const container = this.getContainer(); + if (container) { + document.body.appendChild(container); + } + return container; + } + + public async renderInstallModal(link: string, preferDesktop: boolean) { + await preload(); + const container = this.getMountedContainer(); + const modalProps: InstallWidgetProps = { + onAnalyticsEvent: () => { + //TODO: Remove in a later PR + }, + onClose: this.unload.bind(this), + metaMaskInstaller: { + startDesktopOnboarding: () => { + new MetaMaskOnboarding().startOnboarding(); + this.modal.unmount(); + }, + }, + parentElement: container, + link, + preferDesktop, + sdkVersion: getVersion(), + }; + this.modal = await this.options.installModal.render(modalProps); + this.modal.mount(); + } + + public async renderPendingModal() { + await preload(); + const container = this.getMountedContainer(); + const modalProps: PendingWidgetProps = { + onClose: this.unload.bind(this), + parentElement: container, + sdkVersion: getVersion(), + displayOTP: true, + otpCode: '123456', + updateOTPValue: (_otpValue: string): void => { + throw new Error('Function not implemented.'); + }, + }; + this.modal = await this.options.pendingModal.render(modalProps); + this.modal.mount(); + } + + public async renderSelectModal(link: string, preferDesktop: boolean, connect: () => Promise) { + await preload(); + const container = this.getMountedContainer(); + const modalProps: SelectWidgetProps = { + onClose: this.unload.bind(this), + parentElement: container, + sdkVersion: getVersion(), + connect: () => { + if (this.modal) { + this.modal.unmount(); + } + return connect(); + }, + link, + preferDesktop, + }; + this.modal = await this.options.selectModal.render(modalProps); + this.modal.mount(); + } +} diff --git a/packages/sdk-multichain/src/ui/node/index.test.ts b/packages/sdk-multichain/src/ui/node/index.test.ts new file mode 100644 index 000000000..3211a1994 --- /dev/null +++ b/packages/sdk-multichain/src/ui/node/index.test.ts @@ -0,0 +1,82 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import encodeQR from '@paulmillr/qr'; +import * as t from 'vitest'; +import { vi } from 'vitest'; + +import { AbstractInstallModal, AbstractPendingModal, type Modal } from '../../domain'; +import * as NodeModals from './'; + +vi.mock('@paulmillr/qr', () => { + return { + default: vi.fn().mockReturnValue('qrcode'), + }; +}); + +t.describe('Node Modals', () => { + let modal: Awaited['render']>> | undefined; + + t.afterEach(() => { + modal?.unmount(); + }); + + t.it('Check Modal instances', () => { + t.expect(NodeModals.installModal).toBeInstanceOf(AbstractInstallModal); + t.expect(NodeModals.selectModal).toBeInstanceOf(AbstractInstallModal); + t.expect(NodeModals.pendingModal).toBeInstanceOf(AbstractPendingModal); + }); + + t.it('rendering InstallModal on Node', async () => { + const logSpy = vi.spyOn(console, 'log'); + modal = await NodeModals.installModal.render({ + link: 'https://example.com', + sdkVersion: '1.0.0', + preferDesktop: false, + onClose: vi.fn(), + onAnalyticsEvent: vi.fn(), + metaMaskInstaller: { + startDesktopOnboarding: vi.fn(), + }, + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + t.expect((modal as any).sync).not.toBeDefined(); + modal.mount(); + t.expect(encodeQR).toHaveBeenCalledWith('https://example.com', 'ascii'); + t.expect(logSpy).toHaveBeenCalledWith('qrcode'); + }); + + t.it('Rendering PendingModal on Node', async () => { + //TODO: Modal is currently not doing much but will be a placeholder for the future 2fa modal + modal = await NodeModals.pendingModal.render({ + otpCode: '123456', + onClose: vi.fn(), + updateOTPValue: vi.fn(), + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + t.expect(modal.sync).toBeDefined(); + modal.mount(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + modal.sync!('123456'); + }); + + t.it('Rendering SelectModal on Node', async () => { + //TODO:selectModal Modal is currently not doing much but will be a placeholder for the future 2fa modal + modal = await NodeModals.selectModal.render({ + link: 'https://example.com', + sdkVersion: '1.0.0', + preferDesktop: false, + onClose: vi.fn(), + connect: vi.fn(), + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + modal.mount(); + }); +}); diff --git a/packages/sdk-multichain/src/ui/node/index.ts b/packages/sdk-multichain/src/ui/node/index.ts new file mode 100644 index 000000000..8ab79e023 --- /dev/null +++ b/packages/sdk-multichain/src/ui/node/index.ts @@ -0,0 +1,7 @@ +import { InstallModal } from './install'; +import { PendingModal } from './pending'; +import { SelectModal } from './select'; + +export const installModal = new InstallModal(); +export const pendingModal = new PendingModal(); +export const selectModal = new SelectModal(); diff --git a/packages/sdk-multichain/src/ui/node/install.ts b/packages/sdk-multichain/src/ui/node/install.ts new file mode 100644 index 000000000..1462c0035 --- /dev/null +++ b/packages/sdk-multichain/src/ui/node/install.ts @@ -0,0 +1,18 @@ +import encodeQR from '@paulmillr/qr'; +import { AbstractInstallModal, createLogger, type InstallWidgetProps } from '../../domain'; + +const logger = createLogger('metamask-sdk:ui'); + +export class InstallModal extends AbstractInstallModal { + instance!: HTMLMmInstallModalElement; + async render({ link }: InstallWidgetProps) { + return { + mount: () => { + const qr = encodeQR(link, 'ascii'); + console.log(qr); + logger(`[UI: InstallModal-nodejs()] qrcode url: ${link}`); + }, + unmount: () => {}, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/node/pending.ts b/packages/sdk-multichain/src/ui/node/pending.ts new file mode 100644 index 000000000..40bfd8fba --- /dev/null +++ b/packages/sdk-multichain/src/ui/node/pending.ts @@ -0,0 +1,18 @@ +import { AbstractPendingModal, createLogger, type PendingWidgetProps } from '../../domain'; + +const logger = createLogger('metamask-sdk:ui'); + +export class PendingModal extends AbstractPendingModal { + instance!: HTMLMmPendingModalElement; + async render(_options: PendingWidgetProps) { + return { + mount: () => {}, + unmount: () => {}, + sync: (otpValue: string) => { + if (otpValue !== '') { + logger(`[UI: pendingModal-nodejs: PendingModal()] Choose the following value on your metamask mobile wallet: ${otpValue}`); + } + }, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/node/select.ts b/packages/sdk-multichain/src/ui/node/select.ts new file mode 100644 index 000000000..10aec6555 --- /dev/null +++ b/packages/sdk-multichain/src/ui/node/select.ts @@ -0,0 +1,12 @@ +import { AbstractInstallModal, type SelectWidgetProps } from '../../domain'; + +export class SelectModal extends AbstractInstallModal { + instance!: HTMLMmSelectModalElement; + + async render(_options: SelectWidgetProps) { + return { + mount: () => {}, + unmount: () => {}, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/rn/index.test.ts b/packages/sdk-multichain/src/ui/rn/index.test.ts new file mode 100644 index 000000000..b74092328 --- /dev/null +++ b/packages/sdk-multichain/src/ui/rn/index.test.ts @@ -0,0 +1,68 @@ +/** 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 { AbstractInstallModal, AbstractPendingModal, type Modal } from '../../domain'; +import * as RNModals from './'; + +t.describe('RN Modals', () => { + let modal: Awaited['render']>> | undefined; + + t.afterEach(() => { + modal?.unmount(); + }); + + t.it('Check Modal instances', () => { + t.expect(RNModals.installModal).toBeInstanceOf(AbstractInstallModal); + t.expect(RNModals.selectModal).toBeInstanceOf(AbstractInstallModal); + t.expect(RNModals.pendingModal).toBeInstanceOf(AbstractPendingModal); + }); + + t.it('rendering InstallModal on RN', async () => { + modal = await RNModals.installModal.render({ + link: 'https://example.com', + sdkVersion: '1.0.0', + preferDesktop: false, + onClose: vi.fn(), + onAnalyticsEvent: vi.fn(), + metaMaskInstaller: { + startDesktopOnboarding: vi.fn(), + }, + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + t.expect((modal as any).sync).not.toBeDefined(); + modal.mount(); + }); + + t.it('Rendering PendingModal on RN', async () => { + //TODO: Modal is currently not doing much but will be a placeholder for the future 2fa modal + modal = await RNModals.pendingModal.render({ + otpCode: '123456', + onClose: vi.fn(), + updateOTPValue: vi.fn(), + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + t.expect(modal.sync).toBeDefined(); + modal.mount(); + }); + + t.it('Rendering SelectModal on RN', async () => { + //TODO:selectModal Modal is currently not doing much but will be a placeholder for the future 2fa modal + modal = await RNModals.selectModal.render({ + link: 'https://example.com', + sdkVersion: '1.0.0', + preferDesktop: false, + onClose: vi.fn(), + connect: vi.fn(), + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + modal.mount(); + }); +}); diff --git a/packages/sdk-multichain/src/ui/rn/index.ts b/packages/sdk-multichain/src/ui/rn/index.ts new file mode 100644 index 000000000..8ab79e023 --- /dev/null +++ b/packages/sdk-multichain/src/ui/rn/index.ts @@ -0,0 +1,7 @@ +import { InstallModal } from './install'; +import { PendingModal } from './pending'; +import { SelectModal } from './select'; + +export const installModal = new InstallModal(); +export const pendingModal = new PendingModal(); +export const selectModal = new SelectModal(); diff --git a/packages/sdk-multichain/src/ui/rn/install.ts b/packages/sdk-multichain/src/ui/rn/install.ts new file mode 100644 index 000000000..747fbf173 --- /dev/null +++ b/packages/sdk-multichain/src/ui/rn/install.ts @@ -0,0 +1,11 @@ +import { AbstractInstallModal, type InstallWidgetProps, type SelectWidgetProps } from '../../domain'; + +export class InstallModal extends AbstractInstallModal { + instance!: HTMLMmInstallModalElement; + async render(_options: InstallWidgetProps | SelectWidgetProps) { + return { + mount: () => {}, + unmount: () => {}, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/rn/pending.ts b/packages/sdk-multichain/src/ui/rn/pending.ts new file mode 100644 index 000000000..5b42fef99 --- /dev/null +++ b/packages/sdk-multichain/src/ui/rn/pending.ts @@ -0,0 +1,13 @@ +import { AbstractPendingModal, type PendingWidgetProps } from '../../domain'; + +export class PendingModal extends AbstractPendingModal { + instance!: HTMLMmPendingModalElement; + + async render(_options: PendingWidgetProps) { + return { + mount: () => {}, + unmount: () => {}, + sync: () => {}, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/rn/select.ts b/packages/sdk-multichain/src/ui/rn/select.ts new file mode 100644 index 000000000..a82b596ec --- /dev/null +++ b/packages/sdk-multichain/src/ui/rn/select.ts @@ -0,0 +1,12 @@ +import { AbstractInstallModal, type InstallWidgetProps, type SelectWidgetProps } from '../../domain'; + +export class SelectModal extends AbstractInstallModal { + instance!: HTMLMmSelectModalElement; + + async render(_options: InstallWidgetProps | SelectWidgetProps) { + return { + mount: () => {}, + unmount: () => {}, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/web/index.test.ts b/packages/sdk-multichain/src/ui/web/index.test.ts new file mode 100644 index 000000000..31112b5b5 --- /dev/null +++ b/packages/sdk-multichain/src/ui/web/index.test.ts @@ -0,0 +1,92 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import { JSDOM as Page } from 'jsdom'; +import * as t from 'vitest'; +import { vi } from 'vitest'; + +import { AbstractInstallModal, AbstractPendingModal, type Modal } from '../../domain'; +import * as WebModals from './'; + +t.describe('WEB Modals', () => { + let modal: Awaited['render']>> | undefined; + + t.beforeAll(() => { + const dom = new Page("
", { + url: 'https://dapp.io/', + }); + const globalStub = { + ...dom.window, + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + localStorage: t.vi.fn(), + }; + t.vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', + }); + t.vi.stubGlobal('window', globalStub); + t.vi.stubGlobal('location', dom.window.location); + t.vi.stubGlobal('document', dom.window.document); + }); + + t.afterEach(() => { + modal?.unmount(); + }); + + t.it('Check Modal instances', () => { + t.expect(WebModals.installModal).toBeInstanceOf(AbstractInstallModal); + t.expect(WebModals.selectModal).toBeInstanceOf(AbstractInstallModal); + t.expect(WebModals.pendingModal).toBeInstanceOf(AbstractPendingModal); + }); + + t.it('rendering InstallModal on Web', async () => { + modal = await WebModals.installModal.render({ + parentElement: document.getElementById('root')!, + link: 'https://example.com', + sdkVersion: '1.0.0', + preferDesktop: false, + onClose: vi.fn(), + onAnalyticsEvent: vi.fn(), + metaMaskInstaller: { + startDesktopOnboarding: vi.fn(), + }, + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + t.expect((modal as any).sync).not.toBeDefined(); + modal.mount(); + }); + + t.it('Rendering PendingModal on Web', async () => { + //TODO: Modal is currently not doing much but will be a placeholder for the future 2fa modal + modal = await WebModals.pendingModal.render({ + parentElement: document.getElementById('root')!, + otpCode: '123456', + onClose: vi.fn(), + updateOTPValue: vi.fn(), + onDisconnect: vi.fn(), + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + t.expect(modal.sync).toBeDefined(); + modal.mount(); + }); + + t.it('Rendering SelectModal on Web', async () => { + //TODO:selectModal Modal is currently not doing much but will be a placeholder for the future 2fa modal + modal = await WebModals.selectModal.render({ + parentElement: document.getElementById('root')!, + link: 'https://example.com', + sdkVersion: '1.0.0', + preferDesktop: false, + onClose: vi.fn(), + connect: vi.fn(), + }); + t.expect(modal).toBeDefined(); + t.expect(modal.unmount).toBeDefined(); + t.expect(modal.mount).toBeDefined(); + modal.mount(); + }); +}); diff --git a/packages/sdk-multichain/src/ui/web/index.ts b/packages/sdk-multichain/src/ui/web/index.ts new file mode 100644 index 000000000..8ab79e023 --- /dev/null +++ b/packages/sdk-multichain/src/ui/web/index.ts @@ -0,0 +1,7 @@ +import { InstallModal } from './install'; +import { PendingModal } from './pending'; +import { SelectModal } from './select'; + +export const installModal = new InstallModal(); +export const pendingModal = new PendingModal(); +export const selectModal = new SelectModal(); diff --git a/packages/sdk-multichain/src/ui/web/install.ts b/packages/sdk-multichain/src/ui/web/install.ts new file mode 100644 index 000000000..7fe61c1e0 --- /dev/null +++ b/packages/sdk-multichain/src/ui/web/install.ts @@ -0,0 +1,30 @@ +import { AbstractInstallModal, type InstallWidgetProps } from '../../domain'; + +export class InstallModal extends AbstractInstallModal { + instance!: HTMLMmInstallModalElement; + + async render(options: InstallWidgetProps) { + const modal = document.createElement('mm-install-modal') as HTMLMmInstallModalElement; + + modal.link = options.link; + modal.preferDesktop = options.preferDesktop; + modal.sdkVersion = options.sdkVersion; + modal.addEventListener('close', ({ detail: { shouldTerminate } }) => options.onClose(shouldTerminate)); + + modal.addEventListener('startDesktopOnboarding', options.metaMaskInstaller.startDesktopOnboarding); + + modal.addEventListener('trackAnalytics', ((e: CustomEvent) => options.onAnalyticsEvent?.(e.detail)) as EventListener); + + return { + mount: () => { + options.parentElement?.appendChild(modal); + this.instance = modal; + }, + unmount: () => { + if (options.parentElement?.contains(modal)) { + options.parentElement.removeChild(modal); + } + }, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/web/pending.ts b/packages/sdk-multichain/src/ui/web/pending.ts new file mode 100644 index 000000000..9e45688eb --- /dev/null +++ b/packages/sdk-multichain/src/ui/web/pending.ts @@ -0,0 +1,28 @@ +import { AbstractPendingModal, type PendingWidgetProps } from '../../domain'; + +export class PendingModal extends AbstractPendingModal { + instance!: HTMLMmPendingModalElement; + + async render(options: PendingWidgetProps) { + const modal = document.createElement('mm-pending-modal') as HTMLMmPendingModalElement; + modal.sdkVersion = options.sdkVersion; + modal.displayOTP = options.displayOTP ?? true; + modal.addEventListener('close', options.onClose); + modal.addEventListener('updateOTPValue', ({ detail: { otpValue } }) => options.updateOTPValue(otpValue)); + if (options.onDisconnect) { + modal.addEventListener('disconnect', options.onDisconnect); + } + return { + mount: () => { + options.parentElement?.appendChild(modal); + this.instance = modal; + }, + unmount: () => { + if (options.parentElement?.contains(modal)) { + options.parentElement.removeChild(modal); + } + }, + sync: () => {}, + }; + } +} diff --git a/packages/sdk-multichain/src/ui/web/select.ts b/packages/sdk-multichain/src/ui/web/select.ts new file mode 100644 index 000000000..3e7e3ff75 --- /dev/null +++ b/packages/sdk-multichain/src/ui/web/select.ts @@ -0,0 +1,26 @@ +import { AbstractInstallModal, type SelectWidgetProps } from '../../domain'; + +export class SelectModal extends AbstractInstallModal { + instance!: HTMLMmSelectModalElement; + + async render({ link, sdkVersion, preferDesktop, onClose, connect, parentElement }: SelectWidgetProps) { + const modal = document.createElement('mm-select-modal') as HTMLMmSelectModalElement; + modal.link = link; + modal.sdkVersion = sdkVersion; + modal.preferDesktop = preferDesktop; + modal.addEventListener('close', ({ detail: { shouldTerminate } }) => onClose(shouldTerminate)); + modal.addEventListener('connectWithExtension', connect); + return { + mount: () => { + parentElement?.appendChild(modal); + setTimeout(() => this.updateQRCode(link), 100); + this.instance = modal; + }, + unmount: () => { + if (parentElement?.contains(modal)) { + parentElement.removeChild(modal); + } + }, + }; + } +} diff --git a/packages/sdk-multichain/src/utis/index.test.ts b/packages/sdk-multichain/src/utis/index.test.ts index 30719e6ea..4a49c0522 100644 --- a/packages/sdk-multichain/src/utis/index.test.ts +++ b/packages/sdk-multichain/src/utis/index.test.ts @@ -1,9 +1,10 @@ +/** 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 { MultichainSDKConstructor } from '../domain/multichain'; +import type { MultichainOptions } from '../domain/multichain'; import { getPlatformType, PlatformType } from '../domain/platform'; -import { Store } from '../store'; import * as utils from '.'; vi.mock('../domain/platform', async () => { @@ -15,7 +16,7 @@ vi.mock('../domain/platform', async () => { }); t.describe('Utils', () => { - let options: MultichainSDKConstructor; + let options: MultichainOptions; t.beforeEach(() => { t.vi.clearAllMocks(); @@ -27,7 +28,7 @@ t.describe('Utils', () => { api: { infuraAPIKey: 'testKey', }, - } as MultichainSDKConstructor; + } as MultichainOptions; }); t.describe('getDappId', () => { @@ -66,57 +67,6 @@ t.describe('Utils', () => { const dappSettings = {}; t.expect(utils.getDappId(dappSettings)).toBe('N/A'); }); - - t.describe('Anonymous ID Management', () => { - const mockUuidValue = 'test-uuid-value'; - const data = new Map(); - const mockAnonId = 'test-anon-id'; - let storageMock: Store; - let getAnonIdStorageMock: t.MockInstance<() => Promise>; - let setAnonIdStorageMock: t.MockInstance<(anonId: string) => Promise>; - - t.beforeEach(async () => { - t.vi.clearAllMocks(); - // For some reason mock only works with vi.mock not t.vi.mock ¿? - vi.mock('uuid', () => ({ - v4: vi.fn(() => 'test-uuid-value'), - })); - - const storeClassMock = t.vi.mocked(Store); - storageMock = new storeClassMock({ - getItem: t.vi.fn(async (key: string) => data.get(key) || null), - setItem: t.vi.fn(async (key: string, value: string) => { - data.set(key, value); - }), - deleteItem: t.vi.fn(async (key: string) => { - data.delete(key); - }), - platform: 'web', - }); - getAnonIdStorageMock = t.vi.spyOn(storageMock, 'getAnonId'); - setAnonIdStorageMock = t.vi.spyOn(storageMock, 'setAnonId'); - }); - - t.afterEach(() => { - t.vi.clearAllMocks(); - data.clear(); - }); - - t.describe('getAnonId', () => { - t.it('should return existing _anonId if already set', async () => { - await storageMock.setAnonId(mockAnonId); - const anonId = await utils.getAnonId(storageMock); - t.expect(anonId).toBe(mockAnonId); - t.expect(getAnonIdStorageMock).toHaveBeenCalled(); - }); - t.it('should generate new anonId if not set', async () => { - const anonId = await utils.getAnonId(storageMock); - t.expect(anonId).toBe(mockUuidValue); - t.expect(getAnonIdStorageMock).toHaveBeenCalled(); - t.expect(setAnonIdStorageMock).toHaveBeenCalledWith(mockUuidValue); - }); - }); - }); }); t.describe('getSDKVersion', () => { diff --git a/packages/sdk-multichain/src/utis/index.ts b/packages/sdk-multichain/src/utis/index.ts index 318e9e2c8..e8caaa9d2 100644 --- a/packages/sdk-multichain/src/utis/index.ts +++ b/packages/sdk-multichain/src/utis/index.ts @@ -1,6 +1,6 @@ import * as uuid from 'uuid'; import packageJson from '../../package.json'; -import { type DappSettings, getInfuraRpcUrls, type MultichainSDKConstructor } from '../domain/multichain'; +import { type DappSettings, getInfuraRpcUrls, type MultichainOptions } from '../domain/multichain'; import { getPlatformType, PlatformType } from '../domain/platform'; import type { StoreClient } from '../domain/store/client'; @@ -42,7 +42,7 @@ export const extractFavicon = () => { return favicon; }; -export function setupInfuraProvider(options: MultichainSDKConstructor): MultichainSDKConstructor { +export function setupInfuraProvider(options: MultichainOptions): MultichainOptions { const infuraAPIKey = options.api?.infuraAPIKey; if (!infuraAPIKey) { return options; @@ -59,7 +59,7 @@ export function setupInfuraProvider(options: MultichainSDKConstructor): Multicha return options; } -export function setupDappMetadata(options: MultichainSDKConstructor): MultichainSDKConstructor { +export function setupDappMetadata(options: MultichainOptions): MultichainOptions { const platform = getPlatformType(); const isBrowser = platform === PlatformType.DesktopWeb || platform === PlatformType.MobileWeb || platform === PlatformType.MetaMaskMobileWebview; diff --git a/packages/sdk-multichain/tsconfig.json b/packages/sdk-multichain/tsconfig.json index 29ca1ad9b..515b2c0c8 100644 --- a/packages/sdk-multichain/tsconfig.json +++ b/packages/sdk-multichain/tsconfig.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "useUnknownInCatchVariables": false, "incremental": false, - "lib": ["DOM", "ES2016"], + "lib": ["DOM", "ES2020"], "skipLibCheck": true, "types": ["node"], "paths": { diff --git a/packages/sdk-multichain/tsup.config.ts b/packages/sdk-multichain/tsup.config.ts index ffdaf612f..9b605afd4 100644 --- a/packages/sdk-multichain/tsup.config.ts +++ b/packages/sdk-multichain/tsup.config.ts @@ -162,4 +162,4 @@ export default defineConfig([ outDir: 'dist/types', dts: { only: true }, } -]); \ No newline at end of file +]); diff --git a/packages/sdk-multichain/vitest.config.ts b/packages/sdk-multichain/vitest.config.ts new file mode 100644 index 000000000..5c0185bf1 --- /dev/null +++ b/packages/sdk-multichain/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/.{idea,git,cache,output,temp}/**", + "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*", + "**/fixtures.test.ts", // Exclude fixtures helper file + ], + include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + }, +}); diff --git a/yarn.lock b/yarn.lock index 5d917f05c..9ff0635fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11220,7 +11220,9 @@ __metadata: "@biomejs/biome": 2.0.0 "@metamask/auto-changelog": ^3.4.3 "@metamask/multichain-api-client": ^0.6.4 + "@metamask/onboarding": ^1.0.1 "@metamask/sdk-analytics": "workspace:^" + "@metamask/sdk-install-modal-web": "workspace:^" "@metamask/utils": ^11.4.0 "@paulmillr/qr": ^0.2.1 "@react-native-async-storage/async-storage": ^1.19.6 @@ -11228,7 +11230,7 @@ __metadata: bowser: ^2.11.0 cross-fetch: ^4.1.0 esbuild-plugin-umd-wrapper: ^3.0.0 - eventemitter2: ^6.4.9 + eventemitter3: ^5.0.1 jsdom: ^26.1.0 nock: ^14.0.4 prettier: ^3.3.3 @@ -11544,7 +11546,7 @@ __metadata: languageName: node linkType: hard -"@metamask/sdk-install-modal-web@workspace:*, @metamask/sdk-install-modal-web@workspace:packages/sdk-install-modal-web": +"@metamask/sdk-install-modal-web@workspace:*, @metamask/sdk-install-modal-web@workspace:^, @metamask/sdk-install-modal-web@workspace:packages/sdk-install-modal-web": version: 0.0.0-use.local resolution: "@metamask/sdk-install-modal-web@workspace:packages/sdk-install-modal-web" dependencies: @@ -22632,9 +22634,9 @@ __metadata: linkType: hard "agent-base@npm:^7.1.2": - version: 7.1.4 - resolution: "agent-base@npm:7.1.4" - checksum: 86a7f542af277cfbd77dd61e7df8422f90bac512953709003a1c530171a9d019d072e2400eab2b59f84b49ab9dd237be44315ca663ac73e82b3922d10ea5eafa + version: 7.1.3 + resolution: "agent-base@npm:7.1.3" + checksum: 87bb7ee54f5ecf0ccbfcba0b07473885c43ecd76cb29a8db17d6137a19d9f9cd443a2a7c5fd8a3f24d58ad8145f9eb49116344a66b107e1aeab82cf2383f4753 languageName: node linkType: hard