diff --git a/packages/sdk-multichain/package.json b/packages/sdk-multichain/package.json index e7e9e8ad5..c23c6997e 100644 --- a/packages/sdk-multichain/package.json +++ b/packages/sdk-multichain/package.json @@ -32,6 +32,7 @@ "@biomejs/biome": "2.0.0", "@metamask/auto-changelog": "^3.4.3", "@react-native-async-storage/async-storage": "^1.23.1", + "@types/jsdom": "^21.1.7", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.2.4", "esbuild-plugin-umd-wrapper": "^3.0.0", @@ -53,8 +54,8 @@ }, "license": "MIT", "dependencies": { - "@metamask/mobile-wallet-protocol-core": "^0.1.0", - "@metamask/mobile-wallet-protocol-dapp-client": "^0.1.0", + "@metamask/mobile-wallet-protocol-core": "^0.2.0", + "@metamask/mobile-wallet-protocol-dapp-client": "^0.2.1", "@metamask/multichain-api-client": "^0.8.0", "@metamask/onboarding": "^1.0.1", "@metamask/sdk-analytics": "workspace:^", diff --git a/packages/sdk-multichain/src/config/index.ts b/packages/sdk-multichain/src/config/index.ts index f220dd9ff..0cd7a7e4f 100644 --- a/packages/sdk-multichain/src/config/index.ts +++ b/packages/sdk-multichain/src/config/index.ts @@ -1 +1,4 @@ export const MWP_RELAY_URL = 'wss://mm-sdk-relay.api.cx.metamask.io/connection/websocket'; + +export const METAMASK_CONNECT_BASE_URL = 'https://metamask.app.link/connect'; +export const METAMASK_DEEPLINK_BASE = 'metamask://connect'; diff --git a/packages/sdk-multichain/src/connect.test.ts b/packages/sdk-multichain/src/connect.test.ts index 0c2645651..b565f8425 100644 --- a/packages/sdk-multichain/src/connect.test.ts +++ b/packages/sdk-multichain/src/connect.test.ts @@ -1,10 +1,13 @@ /** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ /** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ import * as t from 'vitest'; -import type { MultichainOptions, MultichainCore, Scope } from './domain'; +import type { MultichainOptions, MultichainCore, Scope, SessionData } from './domain'; // Carefull, order of import matters to keep mocks working -import { runTestsInNodeEnv, type MockedData, mockSessionData, type TestSuiteOptions, runTestsInRNEnv, runTestsInWebEnv } from './fixtures.test'; +import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobileEnv } from '../tests/fixtures.test'; import { Store } from './store'; +import { mockSessionData, mockSessionRequestData } from '../tests/data'; +import type { TestSuiteOptions, MockedData } from '../tests/types'; +import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; async function waitForInstallModal(sdk: MultichainCore) { const onShowInstallModal = t.vi.spyOn(sdk as any, 'showInstallModal'); @@ -44,13 +47,24 @@ function testSuite({ platform, createSDK, options: let sdk: MultichainCore; t.describe(`${platform} tests`, () => { + const isWebEnv = platform === 'web' || platform === 'web-mobile'; + const isMWPPlatform = platform === 'web-mobile' || platform === 'rn' || platform === 'node'; + + const transportString = platform === 'web' ? 'browser' : 'mwp'; let mockedData: MockedData; let testOptions: T; - const transportString = platform === 'web' ? 'browser' : 'mwp'; t.beforeEach(async () => { + const uiOptions: MultichainOptions['ui'] = + platform === 'web-mobile' + ? { + ...originalSdkOptions.ui, + preferDesktop: false, + preferExtension: false, + } + : originalSdkOptions.ui; + 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, @@ -59,6 +73,7 @@ function testSuite({ platform, createSDK, options: enabled: platform !== 'node', integrationType: 'test', }, + ui: uiOptions, storage: new Store({ platform: platform as 'web' | 'rn' | 'node', get(key) { @@ -78,269 +93,182 @@ function testSuite({ platform, createSDK, options: 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.request.mockImplementation((input: any) => { - if (input.method === 'wallet_createSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - return Promise.reject(new Error('Forgot to mock this RPC call?')); - }); - // Create a new SDK instance with the mock configured correctly - const sdk = await createSDK(testOptions); - const onConnectionSuccessSpy = t.vi.spyOn(sdk as any, 'onConnectionSuccess'); + t.it(`${platform} should handle transport connection errors`, async () => { + const connectionError = new Error('Failed to connect transport'); + + //Mock defaultTransport for Extension + Browser + mockedData.mockDefaultTransport.connect.mockRejectedValue(connectionError); + //Mock dappClient for MWP + mockedData.mockDappClient.connect.mockRejectedValue(connectionError); + + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + sdk = await createSDK(testOptions); t.expect(sdk.state).toBe('loaded'); - t.expect(sdk.storage).toBeDefined(); - // There's no default stored transport, so should throw t.expect(() => sdk.provider).toThrow(); t.expect(() => sdk.transport).toThrow(); + // Expect sdk.connect to reject if transport cannot connect + await t.expect(() => sdk.connect(scopes, caipAccountIds)).rejects.toThrow(connectionError); + + //Expect to find all the transport mocks DISCONNECTED + t.expect(mockedData.mockDefaultTransport.__isConnected).toBe(false); + t.expect(mockedData.mockDappClient.state).toBe('DISCONNECTED'); + + mockedData.mockDefaultTransport.connect.mockClear(); + (mockedData.mockDappClient as any).connect.mockClear(); + }); + + t.it(`${platform} should connect transport and create session when not connected`, async () => { const scopes = ['eip155:1'] as Scope[]; const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - let showModalPromise!: Promise; - let unloadSpy!: t.MockInstance<() => void>; + //Empty initial session + mockedData.mockWalletGetSession.mockImplementation(async () => undefined as any); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); - if (platform !== 'web') { - unloadSpy = t.vi.spyOn((sdk as any).options.ui.factory, 'unload'); - showModalPromise = waitForInstallModal(sdk); - } + sdk = await createSDK(testOptions); - const connectPromise = sdk.connect(scopes, caipAccountIds); + t.expect(sdk.state).toBe('loaded'); + t.expect(() => sdk.provider).toThrow(); + t.expect(() => sdk.transport).toThrow(); - if (platform !== 'web') { - //For MWP we simulate a connection with DappClient after showing the QRCode - await expectUIFactoryRenderInstallModal(sdk); + await sdk.connect(scopes, caipAccountIds); - //Emit session_request QRCode back to Modal - await mockedData.mockDappClient.emit('session_request', mockSessionData); - // Connect to MWP using dappClient mock - await mockedData.mockDappClient.connect(); - await showModalPromise; + t.expect(sdk.state).toBe('connected'); + t.expect(sdk.storage).toBeDefined(); + t.expect(sdk.transport).toBeDefined(); + t.expect(() => sdk.provider).toThrow(); - // Should have unloaded the modal and calling successCallback - t.expect(unloadSpy).toHaveBeenCalledWith(); - t.expect(onConnectionSuccessSpy).toHaveBeenCalled(); + if (isMWPPlatform) { + t.expect(mockedData.mockDappClient.state).toBe('CONNECTED'); + t.expect(mockedData.mockDappClient.sendRequest).toHaveBeenCalled(); + } else { + t.expect(mockedData.mockDefaultTransport.__isConnected).toBe(true); + t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalled(); } - await connectPromise; - - t.expect(mockedData.mockTransport.connect).toHaveBeenCalled(); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockedData.mockTransport.isConnected()).toBe(true); + }); - if (platform === 'web') { - // Show install modal should not be called if extension is available - t.expect(mockedData.showInstallModalSpy).not.toHaveBeenCalled(); - t.expect(onConnectionSuccessSpy).toHaveBeenCalled(); - } + t.it(`${platform} should reconnect to the same transport when already connected in the past`, async () => { + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletGetSession.mockImplementation(async () => undefined as any); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); - t.expect(mockedData.mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_createSession', - params: { - optionalScopes: { - ...mockSessionData.sessionScopes, - }, - }, - }); + sdk = await createSDK(testOptions); + t.expect(sdk.state).toBe('connected'); + t.expect(sdk.transport).toBeDefined(); + t.expect(() => sdk.provider).toThrow(); + t.expect(sdk.storage).toBeDefined(); - mockedData.mockTransport.__triggerNotification({ - method: 'wallet_sessionChanged', - params: { - session: mockSessionData, - }, - }); - t.expect(mockedData.emitSpy).toHaveBeenCalledWith('wallet_sessionChanged', mockSessionData); + await t.expect(sdk.storage.getTransport()).resolves.toBe(transportString); }); 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); - mockedData.mockTransport.isConnected.mockReturnValue(true); - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - return Promise.reject(new Error('Forgot to mock this RPC call?')); - }); - mockMultichainClient.getSession.mockResolvedValue(mockSessionData); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + if (isWebEnv) { + await mockedData.mockDefaultTransport.connect(); + } else { + await mockedData.mockDappClient.connect(); + } sdk = await createSDK(testOptions); - t.expect(sdk.provider).toBeDefined(); t.expect(sdk.transport).toBeDefined(); + t.expect(() => sdk.provider).toThrow(); t.expect(sdk.storage).toBeDefined(); - t.expect(mockedData.mockTransport.connect).not.toHaveBeenCalled(); - mockedData.mockTransport.connect.mockReset(); + t.expect(sdk.state).toBe('connected'); + + if (isWebEnv) { + t.expect(mockedData.mockDefaultTransport.__isConnected).toBe(true); + t.expect(mockedData.mockDefaultTransport.connect).toHaveBeenCalled(); + mockedData.mockDefaultTransport.connect.mockClear(); + } else { + t.expect(mockedData.mockDappClient.state).toBe('CONNECTED'); + t.expect(mockedData.mockDappClient.connect).toHaveBeenCalled(); + mockedData.mockDappClient.connect.mockClear(); + } - await sdk.connect(scopes, caipAccountIds); - t.expect(mockedData.mockTransport.connect).not.toHaveBeenCalled(); + await t.expect(sdk.storage.getTransport()).resolves.toBe(transportString); }); t.it(`${platform} should handle invalid CAIP account IDs gracefully`, async () => { - const consoleErrorSpy = t.vi.spyOn(console, 'error').mockImplementation(() => {}); - - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockMultichainClient.getSession.mockResolvedValue(undefined); - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - return Promise.reject(new Error('Forgot to mock this RPC call?')); - }); - // Mock console.error to capture invalid account ID errors - const scopes = ['eip155:1'] as Scope[]; const caipAccountIds = ['invalid-account-id', 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + let unloadSpy!: t.MockInstance<() => void>; + let showModalPromise!: Promise; + + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletGetSession.mockImplementation(async () => undefined as any); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); sdk = await createSDK(testOptions); - const onConnectionSuccessSpy = t.vi.spyOn(sdk as any, 'onConnectionSuccess'); + unloadSpy = t.vi.spyOn((sdk as any).options.ui.factory, 'unload'); - let showModalPromise!: Promise; - let unloadSpy!: t.MockInstance<() => void>; + t.expect(sdk.state).toBe('loaded'); + t.expect(() => sdk.transport).toThrow(); - if (platform !== 'web') { - unloadSpy = t.vi.spyOn((sdk as any).options.ui.factory, 'unload'); + if (platform !== 'web' && platform !== 'web-mobile') { showModalPromise = waitForInstallModal(sdk); } const connectPromise = sdk.connect(scopes, caipAccountIds); - if (platform !== 'web') { - //For MWP we simulate a connection with DappClient after showing the QRCode - await expectUIFactoryRenderInstallModal(sdk); - - //Emit session_request QRCode back to Modal - await mockedData.mockDappClient.emit('session_request', mockSessionData); - // Connect to MWP using dappClient mock - await mockedData.mockDappClient.connect(); - await showModalPromise; + if (isMWPPlatform) { + if (platform !== 'web-mobile') { + (mockedData.mockDappClient as any).__state = 'CONNECTED'; + //For MWP we simulate a connection with DappClient after showing the QRCode + await expectUIFactoryRenderInstallModal(sdk); + } - // Should have unloaded the modal and calling successCallback - t.expect(unloadSpy).toHaveBeenCalledWith(); - t.expect(onConnectionSuccessSpy).toHaveBeenCalled(); + if (platform !== 'web-mobile') { + // Connect to MWP using dappClient mock + mockedData.mockDappClient.connect(); + await showModalPromise; + // Should have unloaded the modal and calling successCallback + t.expect(unloadSpy).toHaveBeenCalledWith(); + } } + await connectPromise; - if (platform === 'web') { - // Show install modal should not be called if extension is available - t.expect(mockedData.showInstallModalSpy).not.toHaveBeenCalled(); - t.expect(onConnectionSuccessSpy).toHaveBeenCalled(); - } + t.expect(sdk.state).toBe('connected'); + t.expect(sdk.storage).toBeDefined(); + t.expect(() => sdk.provider).toThrow(); + t.expect(sdk.transport).toBeDefined(); - t.expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid CAIP account ID: "invalid-account-id"', t.expect.any(Error)); - t.expect(mockedData.mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_createSession', - params: { - optionalScopes: { - 'eip155:1': { - methods: [], - notifications: [], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }, - }); - consoleErrorSpy.mockRestore(); - }); + if (isMWPPlatform) { + t.expect(mockedData.mockDappClient.state).toBe('CONNECTED'); + } else { + t.expect(mockedData.mockDefaultTransport.__isConnected).toBe(true); + } - 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.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(connectionError); + await t.expect(sdk.storage.getTransport()).resolves.toBe(transportString); }); - t.it(`${platform} should gracefully 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; - + t.it(`${platform} should handle session creation errors`, async () => { const sessionError = new Error('Failed to create session'); - - mockMultichainClient.getSession.mockResolvedValue(undefined); - mockMultichainClient.createSession.mockRejectedValue(sessionError); - - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ id: 1, jsonrpc: '2.0', result: undefined }); - } - if (input.method === 'wallet_revokeSession') { - return Promise.resolve({ id: 1, jsonrpc: '2.0', result: mockSessionData }); - } - if (input.method === 'wallet_createSession') { - return Promise.reject(sessionError); - } - return Promise.reject(new Error('Forgot to mock this RPC call?')); - }); const scopes = ['eip155:1'] as Scope[]; const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - sdk = await createSDK(testOptions); - const onConnectionSuccessSpy = t.vi.spyOn(sdk as any, 'onConnectionSuccess'); + mockedData.mockWalletGetSession.mockImplementation(async () => undefined as any); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletCreateSession.mockRejectedValue(sessionError); - let showModalPromise!: Promise; - let unloadSpy!: t.MockInstance<() => void>; - - if (platform !== 'web') { - unloadSpy = t.vi.spyOn((sdk as any).options.ui.factory, 'unload'); - showModalPromise = waitForInstallModal(sdk); - } - - const connectPromise = sdk.connect(scopes, caipAccountIds); - - if (platform !== 'web') { - //For MWP we simulate a connection with DappClient after showing the QRCode - await expectUIFactoryRenderInstallModal(sdk); - - //Emit session_request QRCode back to Modal - await mockedData.mockDappClient.emit('session_request', mockSessionData); - // Connect to MWP using dappClient mock - await mockedData.mockDappClient.connect(); - await showModalPromise; + sdk = await createSDK(testOptions); - // Should have unloaded the modal and calling successCallback - t.expect(unloadSpy).toHaveBeenCalledWith(); - t.expect(onConnectionSuccessSpy).toHaveBeenCalled(); - } - await connectPromise; + t.expect(sdk.state).toBe('loaded'); + t.expect(() => sdk.transport).toThrow(); - // Now test the connect flow with session creation error - await t.expect(mockedData.mockLogger).toHaveBeenCalledWith('MetaMaskSDK error during onConnectionSuccess', sessionError); + await t.expect(() => sdk.connect(scopes, caipAccountIds)).rejects.toThrow(sessionError); }); t.it(`${platform} should handle session revocation errors on session upgrade`, async () => { - const existingSessionData = { + const existingSessionData: SessionData = { ...mockSessionData, sessionScopes: { 'eip155:1': { @@ -353,70 +281,39 @@ function testSuite({ platform, createSDK, options: const revocationError = new Error('Failed to revoke session'); - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: existingSessionData, - }); - } - - if (input.method === 'wallet_revokeSession') { - return Promise.reject(revocationError); - } - return Promise.reject(new Error('Forgot to mock this RPC call?')); + mockedData.mockWalletCreateSession.mockResolvedValue(existingSessionData); + mockedData.mockWalletGetSession.mockResolvedValue(existingSessionData); + mockedData.mockWalletRevokeSession.mockImplementation(async () => { + throw revocationError; }); + t.vi.spyOn(SessionStore.prototype, 'list').mockImplementation(async () => Promise.resolve([await (mockedData as any).mockWalletGetSession()])); - const scopes = ['eip155:137'] as Scope[]; // Same scope as existing session to trigger revocation + const scopes = ['eip155:137'] as Scope[]; // Different scope to trigger upgrade const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - sdk = await createSDK(testOptions); - const onConnectionSuccessSpy = t.vi.spyOn(sdk as any, 'onConnectionSuccess'); - - let showModalPromise!: Promise; - let unloadSpy!: t.MockInstance<() => void>; - - if (platform !== 'web') { - unloadSpy = t.vi.spyOn((sdk as any).options.ui.factory, 'unload'); - showModalPromise = waitForInstallModal(sdk); - } - - const connectPromise = sdk.connect(scopes, caipAccountIds); - - if (platform !== 'web') { - //For MWP we simulate a connection with DappClient after showing the QRCode - await expectUIFactoryRenderInstallModal(sdk); - - //Emit session_request QRCode back to Modal - await mockedData.mockDappClient.emit('session_request', mockSessionData); - // Connect to MWP using dappClient mock - await mockedData.mockDappClient.connect(); - await showModalPromise; + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + sdk = await createSDK(testOptions); - // Should have unloaded the modal and calling successCallback - t.expect(unloadSpy).toHaveBeenCalledWith(); - t.expect(onConnectionSuccessSpy).toHaveBeenCalled(); - } - await connectPromise; + t.expect(sdk.state).toBe('connected'); + t.expect(sdk.storage).toBeDefined(); + t.expect(() => sdk.provider).toThrow(); + t.expect(sdk.transport).toBeDefined(); - // Now test the connect flow with session creation error - await t.expect(mockedData.mockLogger).toHaveBeenCalledWith('MetaMaskSDK error during onConnectionSuccess', revocationError); + await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow(revocationError); }); t.it(`${platform} should disconnect transport successfully`, async () => { - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockedData.mockTransport.isConnected.mockReturnValue(true); - mockMultichainClient.getSession.mockResolvedValue(undefined); - mockMultichainClient.revokeSession.mockReset(); - + mockedData.mockWalletGetSession.mockResolvedValue(mockSessionData); mockedData.nativeStorageStub.setItem('multichain-transport', transportString); sdk = await createSDK(testOptions); await sdk.disconnect(); - t.expect(mockedData.mockTransport.disconnect).toHaveBeenCalled(); + + if (platform === 'web') { + t.expect(mockedData.mockDefaultTransport.disconnect).toHaveBeenCalled(); + } else { + t.expect(mockedData.mockDappClient.disconnect).toHaveBeenCalled(); + } }); t.it(`${platform} should handle disconnect errors`, async () => { @@ -424,38 +321,48 @@ function testSuite({ platform, createSDK, options: const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; const disconnectError = new Error('Failed to disconnect transport'); - mockedData.mockTransport.disconnect.mockRejectedValue(disconnectError); + + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + mockedData.mockWalletGetSession.mockResolvedValue(mockSessionData); + mockedData.mockWalletCreateSession.mockResolvedValue(mockSessionData); + mockedData.mockWalletRevokeSession.mockResolvedValue(undefined); + + if (platform === 'web') { + mockedData.mockDefaultTransport.disconnect.mockRejectedValue(disconnectError); + } else { + mockedData.mockDappClient.disconnect.mockRejectedValue(disconnectError); + } sdk = await createSDK(testOptions); - const onConnectionSuccessSpy = t.vi.spyOn(sdk as any, 'onConnectionSuccess'); + await sdk.connect(scopes, caipAccountIds); + t.expect(sdk.state).toBe('connected'); + t.expect(() => sdk.provider).toThrow(); + t.expect(sdk.transport).toBeDefined(); - let showModalPromise!: Promise; - let unloadSpy!: t.MockInstance<() => void>; + await t.expect(sdk.disconnect()).rejects.toThrow('Failed to disconnect transport'); + }); - if (platform !== 'web') { - unloadSpy = t.vi.spyOn((sdk as any).options.ui.factory, 'unload'); - showModalPromise = waitForInstallModal(sdk); - } + if (platform === 'web-mobile') { + t.it(`${platform} should reconnect to mwp when comming back from Mobile app`, async () => { + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + mockedData.mockWalletGetSession.mockResolvedValue(mockSessionData); + mockedData.mockWalletCreateSession.mockResolvedValue(mockSessionData); + mockedData.mockWalletRevokeSession.mockResolvedValue(undefined); - const connectPromise = sdk.connect(scopes, caipAccountIds); + sdk = await createSDK(testOptions); - if (platform !== 'web') { - //For MWP we simulate a connection with DappClient after showing the QRCode - await expectUIFactoryRenderInstallModal(sdk); + t.expect(sdk.state).toBe('connected'); + t.expect(() => sdk.provider).toThrow(); + t.expect(sdk.transport).toBeDefined(); - //Emit session_request QRCode back to Modal - await mockedData.mockDappClient.emit('session_request', mockSessionData); - // Connect to MWP using dappClient mock - await mockedData.mockDappClient.connect(); - await showModalPromise; + t.expect(mockedData.mockDappClient.state).toBe('CONNECTED'); + await mockedData.mockDappClient.disconnect(); + t.expect(mockedData.mockDappClient.state).toBe('DISCONNECTED'); - // Should have unloaded the modal and calling successCallback - t.expect(unloadSpy).toHaveBeenCalledWith(); - t.expect(onConnectionSuccessSpy).toHaveBeenCalled(); - } - await connectPromise; - await t.expect(sdk.disconnect()).rejects.toThrow('Failed to disconnect transport'); - }); + window.dispatchEvent(new Event('focus')); + t.expect(mockedData.mockDappClient.reconnect).toHaveBeenCalled(); + }); + } }); } @@ -468,3 +375,4 @@ const baseTestOptions = { runTestsInNodeEnv(baseTestOptions, testSuite); runTestsInRNEnv(baseTestOptions, testSuite); runTestsInWebEnv(baseTestOptions, testSuite, exampleDapp.url); +runTestsInWebMobileEnv(baseTestOptions, testSuite, exampleDapp.url); diff --git a/packages/sdk-multichain/src/domain/errors/rpc.ts b/packages/sdk-multichain/src/domain/errors/rpc.ts index 3fbf09e70..006eb0aff 100644 --- a/packages/sdk-multichain/src/domain/errors/rpc.ts +++ b/packages/sdk-multichain/src/domain/errors/rpc.ts @@ -29,6 +29,6 @@ export class RPCReadonlyRequestErr extends BaseErr<'RPC', RPCErrorCodes> { export class RPCInvokeMethodErr extends BaseErr<'RPC', RPCErrorCodes> { static readonly code = 53; constructor(public readonly reason: string) { - super(`RPCErr${RPCInvokeMethodErr.code}: RPC Client invoke method reason ${reason}`, RPCInvokeMethodErr.code); + super(`RPCErr${RPCInvokeMethodErr.code}: RPC Client invoke method reason (${reason})`, RPCInvokeMethodErr.code); } } diff --git a/packages/sdk-multichain/src/domain/multichain/index.ts b/packages/sdk-multichain/src/domain/multichain/index.ts index ee2552e80..b2ce9e1a7 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -25,7 +25,6 @@ export abstract class MultichainCore extends EventEmitter { abstract provider: MultichainApiClient; abstract transport: Transport; - abstract getCurrentSession(): Promise; /** * Establishes a connection to the multichain provider, or re-use existing session * diff --git a/packages/sdk-multichain/src/domain/multichain/types.ts b/packages/sdk-multichain/src/domain/multichain/types.ts index 433e09d58..b8e73f42a 100644 --- a/packages/sdk-multichain/src/domain/multichain/types.ts +++ b/packages/sdk-multichain/src/domain/multichain/types.ts @@ -1,9 +1,11 @@ import type { StoreClient } from '../store'; -import type { MultichainCore, SessionData } from '.'; -import type { RPC_URLS_MAP } from './api/types'; +import type { MultichainCore } from '.'; +import type { RPC_URLS_MAP, Scope } from './api/types'; import type { ModalFactory } from '../../ui'; import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; import type { PlatformType } from '../platform'; +import type { Transport } from '@metamask/multichain-api-client'; +import type { CaipAccountId } from '@metamask/utils'; export type { SessionData } from '@metamask/multichain-api-client'; @@ -56,13 +58,19 @@ export type MultichainOptions = { preferDesktop?: boolean; }; mobile?: { + preferredOpenLink?: (deeplink: string, target?: string) => void; + /** + * The `MetaMaskSDK` constructor option `useDeeplink: boolean` controls which type of link is used: + * - If `true`, the SDK will attempt to use the `metamask://` deeplink. + * - If `false` (the default for web), the SDK will use the `https://metamask.app.link` universal link. + */ useDeeplink?: boolean; }; /** Optional transport configuration */ transport?: { /** Extension ID for browser extension transport */ extensionId?: string; - onResumeSession?: (session: SessionData) => void; + onNotification?: (notification: unknown) => void; }; }; @@ -79,3 +87,7 @@ type MultiChainFNOptions = Omit & { * providing all necessary options for SDK initialization. */ export type CreateMultichainFN = (options: MultiChainFNOptions) => Promise; + +export type ExtendedTransport = Omit & { + connect: (props?: { scopes: Scope[]; caipAccountIds: CaipAccountId[] }) => Promise; +}; diff --git a/packages/sdk-multichain/src/domain/platform/index.ts b/packages/sdk-multichain/src/domain/platform/index.ts index 5e997ee57..f484c1f34 100644 --- a/packages/sdk-multichain/src/domain/platform/index.ts +++ b/packages/sdk-multichain/src/domain/platform/index.ts @@ -74,3 +74,15 @@ export function isMetamaskExtensionInstalled(): boolean { } return Boolean(window.ethereum?.isMetaMask); } + +export function isSecure() { + const platformType = getPlatformType(); + return isReactNative() || platformType === PlatformType.MobileWeb; +} + +export function hasExtension() { + if (typeof window !== 'undefined') { + return window.ethereum?.isMetaMask ?? false; + } + return false; +} diff --git a/packages/sdk-multichain/src/domain/ui/types.ts b/packages/sdk-multichain/src/domain/ui/types.ts index 50dc620ae..b248d2afc 100644 --- a/packages/sdk-multichain/src/domain/ui/types.ts +++ b/packages/sdk-multichain/src/domain/ui/types.ts @@ -15,7 +15,7 @@ export interface InstallWidgetProps extends Components.MmInstallModal { export interface OTPCodeWidgetProps extends Components.MmOtpModal { parentElement?: Element; - onClose: () => void; + onClose: () => Promise; onDisconnect?: () => void; createOTPCode: () => Promise; updateOTPCode: (otpValue: string) => void; diff --git a/packages/sdk-multichain/src/fixtures.test.ts b/packages/sdk-multichain/src/fixtures.test.ts deleted file mode 100644 index 8afab815a..000000000 --- a/packages/sdk-multichain/src/fixtures.test.ts +++ /dev/null @@ -1,533 +0,0 @@ -/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ -/** biome-ignore-all lint/suspicious/noAsyncPromiseExecutor: ok for tests */ -/** 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 - */ -// Additional imports for standardized setup functions -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { JSDOM as Page } from 'jsdom'; -import type { Transport } from '@metamask/multichain-api-client'; -import * as t from 'vitest'; -import { vi } from 'vitest'; -import type { MultichainOptions, MultichainCore, SessionData } from '../src/domain'; -import { MultichainSDK } from '../src/multichain'; - -// Import createSDK functions for convenience -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 webStorage from './store/adapters/web'; -import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; - -// 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 sdk-multichain-ui loader to prevent CJS loading crash -vi.mock('@metamask/sdk-multichain-ui/dist/loader/index.cjs.js', () => ({ - defineCustomElements: vi.fn(), -})); - -vi.mock('../src/multichain/mwp/index.ts', () => { - const mwpMock = vi.fn(); - const createSessionRequest = vi.fn(() => { - return { - id: '1234', - expiresAt: new Date(Date.now() + 60 * 1000), - }; - }); - return { - MWPTransport: mwpMock, - createSessionRequest, - __mockMWPTransport: mwpMock, - __mockCreateSessionRequest: createSessionRequest, - }; -}); - -// Mock DappClient with event emitter functionality -vi.mock('@metamask/mobile-wallet-protocol-dapp-client', () => { - // Create a factory function that returns a new mock instance with event handling - const createMockDappClient = () => { - const eventListeners = new Map void; once: boolean }>>(); - - const mockDappClient = { - connect: vi.fn(() => { - mockDappClient.emit('connected'); - return Promise.resolve(); - }), - disconnect: vi.fn(() => { - mockDappClient.emit('disconnected'); - return Promise.resolve(); - }), - sendRequest: vi.fn(), - resume: vi.fn(), - - // Event handling methods - once: vi.fn((event: string, handler: (...args: any[]) => void) => { - if (!eventListeners.has(event)) { - eventListeners.set(event, []); - } - eventListeners.get(event)!.push({ handler, once: true }); - }), - - on: vi.fn((event: string, handler: (...args: any[]) => void) => { - if (!eventListeners.has(event)) { - eventListeners.set(event, []); - } - eventListeners.get(event)!.push({ handler, once: false }); - }), - - off: vi.fn((event: string, handler?: (...args: any[]) => void) => { - if (!eventListeners.has(event)) return; - - if (handler) { - // Remove specific handler - const listeners = eventListeners.get(event)!; - const index = listeners.findIndex((listener) => listener.handler === handler); - if (index !== -1) { - listeners.splice(index, 1); - } - } else { - // Remove all handlers for this event - eventListeners.delete(event); - } - }), - - // Method to emit events (for testing purposes) - emit: vi.fn((event: string, ...args: any[]) => { - if (!eventListeners.has(event)) return; - - const listeners = eventListeners.get(event)!; - // Create a copy to iterate over, as 'once' handlers will modify the original array - const listenersToCall = [...listeners]; - - listenersToCall.forEach(({ handler, once }) => { - try { - handler(...args); - } catch (error) { - console.error(`Error in event handler for '${event}':`, error); - } - - // Remove 'once' handlers after calling them - if (once) { - const index = listeners.findIndex((l) => l.handler === handler); - if (index !== -1) { - listeners.splice(index, 1); - } - } - }); - }), - - // Helper methods for testing - getEventListeners: vi.fn((event?: string) => { - if (event) { - return eventListeners.get(event) || []; - } - return Object.fromEntries(eventListeners); - }), - - clearEventListeners: vi.fn((event?: string) => { - if (event) { - eventListeners.delete(event); - } else { - eventListeners.clear(); - } - }), - }; - - return mockDappClient; - }; - - // Create a shared instance for backward compatibility - const sharedMockDappClient = createMockDappClient(); - - return { - DappClient: vi.fn().mockImplementation(() => sharedMockDappClient), - __mockDappClient: sharedMockDappClient, - __createMockDappClient: createMockDappClient, - }; -}); - -// Mock WebSocket at the top level -const createMockWebSocket = () => { - const mockWS = { - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3, - readyState: 1, - url: '', - protocol: '', - bufferedAmount: 0, - extensions: '', - binaryType: 'blob' as BinaryType, - onopen: null as ((event: Event) => void) | null, - onmessage: null as ((event: MessageEvent) => void) | null, - onerror: null as ((event: Event) => void) | null, - onclose: null as ((event: CloseEvent) => void) | null, - send: vi.fn(), - close: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }; - return mockWS; -}; - -vi.mock('ws', () => ({ - default: vi.fn().mockImplementation(() => createMockWebSocket()), - WebSocket: vi.fn().mockImplementation(() => createMockWebSocket()), -})); - -// Mock native WebSocket for browser environments -const mockWebSocketConstructor = vi.fn().mockImplementation(() => createMockWebSocket()); -vi.stubGlobal('WebSocket', mockWebSocketConstructor); - -// 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 -vi.mock('@metamask/multichain-api-client', () => { - let currentTransport: Transport | undefined; - - const transport = vi.fn(); - const invokeResponse = vi.fn((req: any) => { - return currentTransport?.request(req); - }); - - const mockMultichainClient = { - createSession: vi.fn(), - getSession: vi.fn(), - revokeSession: vi.fn(), - invokeMethod: invokeResponse, - extendsRpcApi: vi.fn(), - onNotification: vi.fn(), - }; - - return { - getMultichainClient: vi.fn(({ transport: transportToMock }) => { - currentTransport = transportToMock; - return mockMultichainClient; - }), - getDefaultTransport: vi.fn(() => { - currentTransport = transport(); - return currentTransport; - }), - // Export the mocks so tests can access them - __mockMultichainClient: mockMultichainClient, - __mockTransport: transport, - }; -}); - -type GetItem = (key: string) => string | null; -type SetItem = (key: string, value: string) => void; -type RemoveItem = (key: string) => void; -type Clear = () => void; - -export type NativeStorageStub = { - data: Map; - getItem: t.Mock; - setItem: t.Mock; - removeItem: t.Mock; - clear: t.Mock; -}; - -export type MockedData = { - initSpy: t.MockInstance; - setupAnalyticsSpy: t.MockInstance; - emitSpy: t.MockInstance; - showInstallModalSpy: t.MockInstance; - nativeStorageStub: NativeStorageStub; - mockTransport: t.Mocked void }>; - mockMultichainClient: any; - mockWebSocket: any; - mockDappClient: t.Mocked; - mockLogger: t.MockInstance; -}; - -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': { - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - methods: [], - notifications: [], - }, - }, - expiry: new Date(Date.now() + 3600000).toISOString(), -}; - -// Standardized setup functions for each platform -export const setupNodeMocks = (nativeStorageStub: NativeStorageStub) => { - // Mock console.log to prevent QR codes from displaying in test output - t.vi.spyOn(console, 'log').mockImplementation(() => {}); - t.vi.spyOn(console, 'clear').mockImplementation(() => {}); - - t.vi.spyOn(nodeStorage, 'StoreAdapterNode').mockImplementation(() => { - const __storage = { - get: t.vi.fn((key: string) => nativeStorageStub.getItem(key)), - set: t.vi.fn((key: string, value: string) => nativeStorageStub.setItem(key, value)), - delete: t.vi.fn((key: string) => nativeStorageStub.removeItem(key)), - platform: 'node' as const, - get storage() { - return __storage; - }, - } as any; - return __storage; - }); -}; - -export const setupRNMocks = (nativeStorageStub: NativeStorageStub) => { - // Mock console.log to prevent QR codes from displaying in test output (for consistency) - t.vi.spyOn(console, 'log').mockImplementation(() => {}); - t.vi.spyOn(console, 'clear').mockImplementation(() => {}); - - 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.removeItem(key)); -}; - -export const setupWebMocks = (nativeStorageStub: NativeStorageStub, dappUrl = 'https://test.dapp') => { - const dom = new Page('

Hello world

', { - url: dappUrl, - }); - const globalStub = { - ...dom.window, - addEventListener: t.vi.fn(), - removeEventListener: t.vi.fn(), - 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(() => { - const __storage = { - get: t.vi.fn((key: string) => { - return nativeStorageStub.getItem(key); - }), - set: t.vi.fn((key: string, value: string) => { - return nativeStorageStub.setItem(key, value); - }), - delete: t.vi.fn((key: string) => { - return nativeStorageStub.removeItem(key); - }), - platform: 'web' as const, - get storage() { - return __storage; - }, - } as any; - return __storage; - }); -}; - -// Helper functions to create standardized test configurations -export const runTestsInNodeEnv = (options: T, testSuite: (options: TestSuiteOptions) => void) => { - return createTest({ - platform: 'node', - createSDK: createMetamaskSDKNode, - options, - setupMocks: setupNodeMocks, - tests: testSuite, - }); -}; - -export const runTestsInRNEnv = (options: T, testSuite: (options: TestSuiteOptions) => void) => { - return createTest({ - platform: 'rn', - createSDK: createMetamaskSDKRN, - options, - setupMocks: setupRNMocks, - tests: testSuite, - }); -}; - -export const runTestsInWebEnv = (options: T, testSuite: (options: TestSuiteOptions) => void, dappUrl?: string) => { - return createTest({ - platform: 'web', - createSDK: createMetamaskSDKWeb, - options, - setupMocks: (nativeStorageStub) => setupWebMocks(nativeStorageStub, dappUrl), - tests: testSuite, - }); -}; - -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; - let showInstallModalSpy!: t.MockInstance; - let dappClientMock!: t.Mocked; - let mockLogger!: t.MockInstance; - - async function beforeEach() { - const mockMultichainClient = ((await import('@metamask/multichain-api-client')) as any).__mockMultichainClient; - const mwpTransportMock = ((await import('./multichain/mwp/index.ts')) as any).__mockMWPTransport; - const defaultTransportMock = ((await import('@metamask/multichain-api-client')) as any).__mockTransport; - - mockLogger = ((await import('./domain/logger')) as any).__mockLogger; - dappClientMock = ((await import('@metamask/mobile-wallet-protocol-dapp-client')) as any).__mockDappClient; - - const initialTransportSpy = { - initialTransport: true, - - connect: vi.fn(() => { - initialTransportSpy.__isConnected = true; - }), - disconnect: vi.fn(() => { - initialTransportSpy.__isConnected = false; - }), - isConnected: vi.fn(() => { - return initialTransportSpy.__isConnected; - }), - request: vi.fn(), - onNotification: vi.fn((callback: (data: any) => void) => { - initialTransportSpy.__notificationCallback = callback; - return () => { - initialTransportSpy.__notificationCallback = null; - }; - }), - - __isConnected: false, - __notificationCallback: null as ((data: any) => void) | null, - __triggerNotification: vi.fn((data: any) => { - if (initialTransportSpy.__notificationCallback) { - initialTransportSpy.__notificationCallback(data); - } - }), - }; - - defaultTransportMock.mockImplementation(() => initialTransportSpy); - - mwpTransportMock.mockImplementation(() => initialTransportSpy); - - // Clear all mocks first - t.vi.clearAllMocks(); - - // Reset multichain client mocks with default implementations - mockMultichainClient.createSession.mockResolvedValue(mockSessionData); - mockMultichainClient.getSession.mockResolvedValue(mockSessionData); - - // Create storage stub using the mocked class - 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(); - }), - }; - - // 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'); - showInstallModalSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'showInstallModal'); - - // Setup platform-specific mocks - setupMocks?.(nativeStorageStub); - - return { - initSpy, - setupAnalyticsSpy, - emitSpy, - showInstallModalSpy, - nativeStorageStub, - mockTransport: initialTransportSpy as any, - mockMultichainClient, - mockWebSocket: mockWebSocketConstructor, - mockDappClient: dappClientMock, - mockLogger, - }; - } - - async function afterEach(mocks: MockedData) { - // Clear storage - mocks.nativeStorageStub.data.clear(); - - // Restore spies - mocks.setupAnalyticsSpy?.mockRestore(); - mocks.initSpy?.mockRestore(); - mocks.emitSpy?.mockRestore(); - mocks.showInstallModalSpy?.mockRestore(); - - // Clear all mocks - t.vi.clearAllMocks(); - - // Run custom cleanup - cleanupMocks?.(); - } - - tests({ - platform, - createSDK, - options, - beforeEach, - afterEach, - storage: nativeStorageStub, - }); -}; diff --git a/packages/sdk-multichain/src/init.test.ts b/packages/sdk-multichain/src/init.test.ts index 9db96613d..505748979 100644 --- a/packages/sdk-multichain/src/init.test.ts +++ b/packages/sdk-multichain/src/init.test.ts @@ -2,11 +2,13 @@ /** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ import * as t from 'vitest'; import type { MultichainOptions, MultichainCore } from './domain'; -import { runTestsInNodeEnv, type MockedData, mockSessionData, type TestSuiteOptions, runTestsInRNEnv, runTestsInWebEnv } from './fixtures.test'; +import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobileEnv } from '../tests/fixtures.test'; // Carefull, order of import matters to keep mocks working import { analytics } from '@metamask/sdk-analytics'; import * as loggerModule from './domain/logger'; +import type { TestSuiteOptions, MockedData } from '../tests/types'; +import { mockSessionData, mockSessionRequestData } from '../tests/data'; function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { const { beforeEach, afterEach } = options; @@ -19,9 +21,19 @@ function testSuite({ platform, createSDK, options: const transportString = platform === 'web' ? 'browser' : 'mwp'; t.beforeEach(async () => { + const uiOptions: MultichainOptions['ui'] = + platform === 'web-mobile' + ? { + ...originalSdkOptions.ui, + preferDesktop: false, + preferExtension: false, + } + : originalSdkOptions.ui; + mockedData = await beforeEach(); testOptions = { ...originalSdkOptions, + ui: uiOptions, analytics: { ...originalSdkOptions.analytics, enabled: platform === 'web', @@ -74,74 +86,52 @@ function testSuite({ platform, createSDK, options: t.expect(loggerModule.enableDebug).toHaveBeenCalledWith('metamask-sdk:core'); }); + t.it(`${platform} should properly initialize if no transport is found during init`, async () => { + sdk = await createSDK(testOptions); + t.expect(sdk.state).toBe('loaded'); + t.expect(() => sdk.transport).toThrow(); + }); + 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.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: undefined, - }); - }); + + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + sdk = await createSDK(testOptions); t.expect(sdk.state).toBe('connected'); - await sdk.connect(['eip155:1'], ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any); - 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 () => { + t.it(`${platform} should emit stateChanged 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); - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: undefined, - }); - }); - const onResumeSession = t.vi.fn(); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); + const onNotification = t.vi.fn(); const optionsWithEvent = { ...testOptions, transport: { ...(testOptions.transport ?? {}), - onResumeSession, + onNotification: onNotification, }, }; sdk = await createSDK(optionsWithEvent); t.expect(sdk).toBeDefined(); - t.expect(sdk.state).toBe('connected'); - // Check that sessionChanged event was emitted with the expected session data during initialization - t.expect(onResumeSession).toHaveBeenCalledWith(mockSessionData); + t.expect(sdk.state).toBe('connected'); + t.expect(onNotification).toHaveBeenCalledWith({ + method: 'stateChanged', + params: 'connected', + }); }); t.it(`${platform} Should gracefully handle init errors by just logging them and return non initialized sdk`, async () => { @@ -169,6 +159,7 @@ const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; const baseTestOptions = { dapp: exampleDapp } as any; -runTestsInWebEnv(baseTestOptions, testSuite, 'https://dapp.io/'); runTestsInNodeEnv(baseTestOptions, testSuite); runTestsInRNEnv(baseTestOptions, testSuite); +runTestsInWebEnv(baseTestOptions, testSuite, exampleDapp.url); +runTestsInWebMobileEnv(baseTestOptions, testSuite, exampleDapp.url); diff --git a/packages/sdk-multichain/src/invoke.test.ts b/packages/sdk-multichain/src/invoke.test.ts index c7262c478..adabbdecb 100644 --- a/packages/sdk-multichain/src/invoke.test.ts +++ b/packages/sdk-multichain/src/invoke.test.ts @@ -2,10 +2,13 @@ /** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ import * as t from 'vitest'; import { vi } from 'vitest'; -import type { InvokeMethodOptions, MultichainOptions, MultichainCore } from './domain'; +import type { InvokeMethodOptions, MultichainOptions, MultichainCore, Scope } from './domain'; // Carefull, order of import matters to keep mocks working -import { mockSessionData, runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, type MockedData, type TestSuiteOptions } from './fixtures.test'; +import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobileEnv } from '../tests/fixtures.test'; import { Store } from './store'; +import { mockSessionData, mockSessionRequestData } from '../tests/data'; +import type { TestSuiteOptions, MockedData } from '../tests/types'; +import { RPCClient } from './multichain/rpc/client'; vi.mock('cross-fetch', () => { const mockFetch = vi.fn(); @@ -15,19 +18,60 @@ vi.mock('cross-fetch', () => { }; }); +async function waitForInstallModal(sdk: MultichainCore) { + const onShowInstallModal = t.vi.spyOn(sdk as any, 'showInstallModal'); + + let attempts = 5; + while (attempts > 0) { + try { + t.expect(onShowInstallModal).toHaveBeenCalled(); + break; + } catch { + attempts--; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + t.expect(onShowInstallModal).toHaveBeenCalled(); +} + +async function expectUIFactoryRenderInstallModal(sdk: MultichainCore) { + const onRenderInstallModal = t.vi.spyOn((sdk as any).options.ui.factory, 'renderInstallModal'); + + let attempts = 5; + while (attempts > 0) { + try { + t.expect(onRenderInstallModal).toHaveBeenCalled(); + break; + } catch { + attempts--; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + t.expect(onRenderInstallModal).toHaveBeenCalled(); +} + function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { const { beforeEach, afterEach } = options; const originalSdkOptions = sdkOptions; let sdk: MultichainCore; t.describe(`${platform} tests`, () => { + const isMWPPlatform = platform === 'web-mobile' || platform === 'rn' || platform === 'node'; + let mockedData: MockedData; let testOptions: T; const transportString = platform === 'web' ? 'browser' : 'mwp'; t.beforeEach(async () => { + const uiOptions: MultichainOptions['ui'] = + platform === 'web-mobile' + ? { + ...originalSdkOptions.ui, + preferDesktop: false, + preferExtension: false, + } + : originalSdkOptions.ui; 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, @@ -36,6 +80,7 @@ function testSuite({ platform, createSDK, options: enabled: platform !== 'node', integrationType: 'test', }, + ui: uiOptions, storage: new Store({ platform: platform as 'web' | 'rn' | 'node', get(key) { @@ -56,58 +101,80 @@ function testSuite({ platform, createSDK, options: }); t.it(`${platform} should invoke method successfully from provider with an active session and connected transport`, async () => { - // Mock the RPCClient response - const mockResponse = { id: 1, jsonrpc: '2.0' as const, result: 'success' }; - mockedData.nativeStorageStub.setItem('multichain-transport', transportString); - mockedData.mockTransport.request.mockResolvedValue(mockResponse); - mockedData.mockTransport.isConnected.mockReturnValue(true); - mockedData.mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletGetSession.mockImplementation(async () => undefined as any); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + mockedData.mockWalletInvokeMethod.mockImplementation(async () => { + return { + id: 1, + jsonrpc: '2.0', + result: 'success', + }; + }); 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 reject invoke if no active session or provider is available`, async () => { - mockedData.nativeStorageStub.removeItem('multichain-transport'); + t.expect(sdk.state).toBe('loaded'); + t.expect(() => sdk.provider).toThrow(); + t.expect(() => sdk.transport).toThrow(); - mockedData.mockTransport.isConnected.mockReturnValue(false); - mockedData.mockTransport.connect.mockResolvedValue(); - mockedData.mockMultichainClient.getSession.mockResolvedValue(undefined); + await sdk.connect(scopes, caipAccountIds); - sdk = await createSDK(testOptions); + t.expect(sdk.state).toBe('connected'); + t.expect(sdk.storage).toBeDefined(); + t.expect(sdk.transport).toBeDefined(); + const providerInvokeMethodSpy = t.vi.spyOn(RPCClient.prototype, 'invokeMethod'); const options = { + id: 1, scope: 'eip155:1', request: { method: 'eth_accounts', params: [] }, } as InvokeMethodOptions; - await t.expect(sdk.invokeMethod(options)).rejects.toThrow('Provider not initialized, establish connection first'); + const result = await sdk.invokeMethod(options); + t.expect(providerInvokeMethodSpy).toHaveBeenCalledWith(options); + t.expect(result).toEqual({ + id: 1, + jsonrpc: '2.0', + result: 'success', + }); }); + t.it( + `${platform} should reject invoke in case of failure in RPCClient`, + async () => { + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + mockedData.mockWalletInvokeMethod.mockRejectedValue(new Error('Failed to invoke method')); + + sdk = await createSDK(testOptions); + await sdk.connect(scopes, caipAccountIds); + t.expect(sdk.state).toBe('connected'); + + const options = { + scope: 'eip155:1', + request: { method: 'eth_accounts', params: [] }, + } as InvokeMethodOptions; + + await t.expect(sdk.invokeMethod(options)).rejects.toThrow('RPCErr53: RPC Client invoke method reason (Failed to invoke method)'); + }, + { timeout: 100000 }, + ); + t.it(`${platform} should invoke readonly method successfully from client if infuraAPIKey exists`, async () => { - mockedData.nativeStorageStub.setItem('multichain-transport', transportString); - mockedData.mockTransport.isConnected.mockReturnValue(true); - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: undefined, - }); - }); + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + // Mock the RPCClient response const mockJsonResponse = { result: 'success' }; const fetchModule = await import('cross-fetch'); @@ -127,16 +194,17 @@ function testSuite({ platform, createSDK, options: }, }); + t.expect(sdk.state).toBe('loaded'); + t.expect(() => sdk.provider).toThrow(); + t.expect(() => sdk.transport).toThrow(); + + await sdk.connect(scopes, caipAccountIds); + t.expect(sdk.state).toBe('connected'); - const providerInvokeMethodSpy = t.vi.spyOn(sdk.provider, 'invokeMethod'); - const options = { - scope: 'eip155:1', - request: { method: 'eth_accounts', params: [] }, - } as InvokeMethodOptions; + 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); }); @@ -144,21 +212,13 @@ function testSuite({ platform, createSDK, options: t.it(`${platform} should handle invoke method errors`, async () => { const mockError = new Error('Failed to invoke method'); mockedData.nativeStorageStub.setItem('multichain-transport', transportString); - mockedData.mockTransport.isConnected.mockReturnValue(true); - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: undefined, - }); - }); + + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); + mockedData.mockWalletInvokeMethod.mockRejectedValue(mockError); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); + sdk = await createSDK(testOptions); const options = { scope: 'eip155:1', @@ -168,8 +228,7 @@ function testSuite({ platform, createSDK, options: }, } as InvokeMethodOptions; t.expect(sdk.state).toBe('connected'); - t.expect(sdk.provider).toBeDefined(); - mockedData.mockTransport.request.mockRejectedValue(mockError); + t.expect(() => sdk.provider).toThrow(); await t.expect(sdk.invokeMethod(options)).rejects.toThrow('Failed to invoke method'); }); @@ -180,6 +239,7 @@ const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; const baseTestOptions = { dapp: exampleDapp } as any; -runTestsInWebEnv(baseTestOptions, testSuite, exampleDapp.url); runTestsInNodeEnv(baseTestOptions, testSuite); runTestsInRNEnv(baseTestOptions, testSuite); +runTestsInWebEnv(baseTestOptions, testSuite, exampleDapp.url); +runTestsInWebMobileEnv(baseTestOptions, testSuite, exampleDapp.url); diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 595f457c8..7f510f1a9 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -1,29 +1,43 @@ -import { type CreateSessionParams, getDefaultTransport, getMultichainClient, type MultichainApiClient, type SessionData, type Transport } from '@metamask/multichain-api-client'; +import { getMultichainClient, type MultichainApiClient, type SessionData } from '@metamask/multichain-api-client'; import { analytics } from '@metamask/sdk-analytics'; import type { CaipAccountId, Json } from '@metamask/utils'; import packageJson from '../../package.json'; -import { type InvokeMethodOptions, type ModalFactoryConnectOptions, type MultichainOptions, type RPCAPI, type Scope, TransportType } from '../domain'; +import { type InvokeMethodOptions, type MultichainOptions, type RPCAPI, type Scope, TransportType } from '../domain'; import { createLogger, enableDebug, isEnabled as isLoggerEnabled } from '../domain/logger'; -import { type ConnectionRequest, MultichainCore, type SDKState } from '../domain/multichain'; -import { getPlatformType, PlatformType } from '../domain/platform'; -import { MWPTransport } from './mwp'; +import { type ConnectionRequest, type ExtendedTransport, MultichainCore, type SDKState } from '../domain/multichain'; +import { getPlatformType, hasExtension, isSecure, PlatformType } from '../domain/platform'; import { RPCClient } from './rpc/client'; -import { addValidAccounts, getDappId, getOptionalScopes, getValidAccounts, getVersion, setupDappMetadata, setupInfuraProvider } from './utils'; +import { getDappId, getVersion, setupDappMetadata, setupInfuraProvider } from './utils'; import { ErrorCode, ProtocolError, type SessionRequest, SessionStore, WebSocketTransport } from '@metamask/mobile-wallet-protocol-core'; -import { MWP_RELAY_URL } from 'src/config'; +import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE, MWP_RELAY_URL } from 'src/config'; import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; -import { keymanager } from './mwp/KeyManager'; + +import { MWPTransport } from './transports/mwp'; +import { keymanager } from './transports/mwp/KeyManager'; +import { DefaultTransport } from './transports/default'; //ENFORCE NAMESPACE THAT CAN BE DISABLED const logger = createLogger('metamask-sdk:core'); + export class MultichainSDK extends MultichainCore { private __provider: MultichainApiClient | undefined = undefined; - private __transport: Transport | undefined = undefined; + private __transport: ExtendedTransport | undefined = undefined; private __dappClient: DappClient | undefined = undefined; - public state: SDKState; + public __state: SDKState = 'pending'; private listener: (() => void | Promise) | undefined; + get state() { + return this.__state; + } + set state(value: SDKState) { + this.__state = value; + this.options.transport?.onNotification?.({ + method: 'stateChanged', + params: value, + }); + } + get provider() { if (!this.__provider) { throw new Error('Provider not initialized, establish connection first'); @@ -45,27 +59,14 @@ export class MultichainSDK extends MultichainCore { return this.__dappClient; } - async getCurrentSession(): Promise { - try { - //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 getCurrentSession', err); - return undefined; - } - } - get storage() { return this.options.storage; } + private get sdkInfo() { + return `Sdk/Javascript SdkVersion/${packageJson.version} Platform/${getPlatformType()} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${this.options.dapp.name}`; + } + private constructor(options: MultichainOptions) { const withInfuraRPCMethods = setupInfuraProvider(options); const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); @@ -85,7 +86,6 @@ export class MultichainSDK extends MultichainCore { }; super(allOptions); - this.state = 'pending'; } static async create(options: MultichainOptions) { @@ -125,65 +125,49 @@ export class MultichainSDK extends MultichainCore { analytics.enable(); } - private async onTransportNotification(payload: unknown) { - if (typeof payload === 'object' && payload !== null && 'data' in payload) { - const data = payload.data as Record; - if ('method' in data && data.method === 'wallet_sessionChanged') { - const session = data.params as SessionData; - this.emit('wallet_sessionChanged', Object.keys(session?.sessionScopes ?? {}).length > 0 ? session : undefined); - } else { - this.emit(data.method as string, data.params); - } + private async onTransportNotification(payload: any) { + if (typeof payload === 'object' && payload !== null && 'method' in payload) { + this.emit(payload.method as string, payload.params || payload.result); } } private async getStoredTransport() { + const { ui } = this.options; + const { preferExtension = true, headless: _headless = false } = ui; const transportType = await this.storage.getTransport(); if (transportType) { if (transportType === TransportType.Browser) { - const apiTransport = getDefaultTransport(this.options.transport); - this.__transport = apiTransport; - this.listener = apiTransport.onNotification(this.onTransportNotification.bind(this)); - return apiTransport; + //Check if the user still have the extension or not return the transport + if (hasExtension() && preferExtension) { + const apiTransport = new DefaultTransport(); + this.__transport = apiTransport; + this.listener = apiTransport.onNotification(this.onTransportNotification.bind(this)); + return apiTransport; + } } else if (transportType === TransportType.MPW) { const { adapter: kvstore } = this.options.storage; - const sessionstore = new SessionStore(kvstore); - const websocket = typeof window !== 'undefined' ? WebSocket : (await import('ws')).WebSocket; - const transport = await WebSocketTransport.create({ url: MWP_RELAY_URL, kvstore, websocket }); - const dappClient = new DappClient({ transport, sessionstore, keymanager }); + const dappClient = await this.createDappClient(); const apiTransport = new MWPTransport(dappClient, kvstore); this.__dappClient = dappClient; this.__transport = apiTransport; this.listener = apiTransport.onNotification(this.onTransportNotification.bind(this)); return apiTransport; - } else { - await this.storage.removeTransport(); } + await this.storage.removeTransport(); } - return undefined; - } - private async getActiveSession() { - if (!this.transport.isConnected()) { - await this.transport.connect(); - } - const request = await this.transport.request({ method: 'wallet_getSession' }); - const response = request.result as SessionData; - return response; + return undefined; } private async setupTransport() { const transport = await this.getStoredTransport(); if (transport) { - const session = await this.getActiveSession(); - this.__provider = getMultichainClient({ transport }); - //Add event listeners to the transport - if (session && Object.keys(session.sessionScopes ?? {}).length > 0) { - // No listeners can exist in here so we need onResumeSession event on constructor - this.options.transport?.onResumeSession?.(session); + if (!this.transport.isConnected()) { + this.state = 'connecting'; + await this.transport.connect(); this.state = 'connected'; } else { - this.state = 'loaded'; + this.state = 'connected'; } } else { this.state = 'loaded'; @@ -207,149 +191,220 @@ export class MultichainSDK extends MultichainCore { } } - private get hasExtension() { - if (typeof window !== 'undefined') { - return window.ethereum?.isMetaMask ?? false; - } - return false; + private async createDappClient() { + const { adapter: kvstore } = this.options.storage; + const sessionstore = new SessionStore(kvstore); + const websocket = typeof window !== 'undefined' ? WebSocket : (await import('ws')).WebSocket; + const transport = await WebSocketTransport.create({ url: MWP_RELAY_URL, kvstore, websocket }); + const dappClient = new DappClient({ transport, sessionstore, keymanager }); + return dappClient; } - private async onConnectionSuccess(params: ModalFactoryConnectOptions) { - if (!this.transport.isConnected()) { - await this.transport.connect(); + private async setupMWP() { + if (this.__transport instanceof MWPTransport) { + return; } - try { - this.state = 'connected'; - 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('wallet_sessionChanged', session); - return; - } - if (session) { - await this.transport.request({ method: 'wallet_revokeSession', params: session }); + //Only setup MWP if it is not already mwp + const { adapter: kvstore } = this.options.storage; + const dappClient = await this.createDappClient(); + this.__dappClient = dappClient; + const apiTransport = new MWPTransport(dappClient, kvstore); + this.__transport = apiTransport; + this.listener = this.transport.onNotification(this.onTransportNotification.bind(this)); + await this.storage.setTransport(TransportType.MPW); + } + + private openDeeplink(deeplink: string, universalLink: string) { + const { mobile } = this.options; + const useDeeplink = mobile && mobile.useDeeplink !== undefined ? mobile.useDeeplink : true; + if (useDeeplink) { + if (typeof window !== 'undefined') { + // We don't need to open a deeplink in a new tab + // It avoid the browser to display a blank page + window.location.href = deeplink; } - const { scopes, caipAccountIds } = params; - const optionalScopes = addValidAccounts(getOptionalScopes(scopes), getValidAccounts(caipAccountIds)); - const sessionRequest: CreateSessionParams = { optionalScopes }; - const newSessionRequest = await this.transport.request({ method: 'wallet_createSession', params: sessionRequest }); - const newSession = newSessionRequest.result as SessionData; - this.options.transport?.onResumeSession?.(newSession); - this.emit('wallet_sessionChanged', newSession); - } catch (error) { - logger('MetaMaskSDK error during onConnectionSuccess', error); + } else if (typeof document !== 'undefined') { + // Workaround for https://github.com/rainbow-me/rainbowkit/issues/524. + // Using 'window.open' causes issues on iOS in non-Safari browsers and + // WebViews where a blank tab is left behind after connecting. + // This is especially bad in some WebView scenarios (e.g. following a + // link from Twitter) where the user doesn't have any mechanism for + // closing the blank tab. + // For whatever reason, links with a target of "_blank" don't suffer + // from this problem, and programmatically clicking a detached link + // element with the same attributes also avoids the issue. + const link = document.createElement('a'); + link.href = universalLink; + link.target = '_self'; + link.rel = 'noreferrer noopener'; + link.click(); } } private async showInstallModal(desktopPreferred: boolean, scopes: Scope[], caipAccountIds: CaipAccountId[]) { - let connectionRequest: ConnectionRequest | undefined; return new Promise((resolve, reject) => { - this.setupMWP() - .then(() => { - this.options.ui.factory.renderInstallModal( - desktopPreferred, - () => { - return new Promise((resolveConnectionRequest) => { - this.dappClient.on('session_request', (sessionRequest: SessionRequest) => { - connectionRequest = { - sessionRequest, - metadata: { - dapp: this.options.dapp, - sdk: { - version: getVersion(), - platform: getPlatformType(), - }, - }, - }; - resolveConnectionRequest(connectionRequest); - }); - this.transport.connect().catch((err) => { - if (err instanceof ProtocolError) { - //Ignore Request expired errors to allow modal to regenerate expired qr codes - if (err.code !== ErrorCode.REQUEST_EXPIRED) { - reject(err); - } - } else { + // Use Connection Modal + this.options.ui.factory.renderInstallModal( + desktopPreferred, + async () => { + return new Promise((resolveConnectionRequest) => { + this.dappClient.on('session_request', (sessionRequest: SessionRequest) => { + resolveConnectionRequest({ + sessionRequest, + metadata: { + dapp: this.options.dapp, + sdk: { + version: getVersion(), + platform: getPlatformType(), + }, + }, + }); + }); + + this.transport + .connect({ scopes, caipAccountIds }) + .then(() => { + this.options.ui.factory.unload(); + this.options.ui.factory.modal?.unmount(); + }) + .catch((err) => { + if (err instanceof ProtocolError) { + //Ignore Request expired errors to allow modal to regenerate expired qr codes + if (err.code !== ErrorCode.REQUEST_EXPIRED) { reject(err); } - }); + // If request is expires, the QRCode will automatically be regenerated we can ignore this case + } else { + reject(err); + } }); - }, - (error?: Error) => { - if (!error) { - this.onConnectionSuccess({ scopes, caipAccountIds }).then(resolve).catch(reject); - } else { - this.state = 'disconnected'; - reject(error); - } - }, - ); - }) - .catch(reject); + }); + }, + async (error?: Error) => { + if (!error) { + await this.storage.setTransport(TransportType.MPW); + this.state = 'connected'; + resolve(); + } else { + this.state = 'disconnected'; + await this.storage.removeTransport(); + reject(error); + } + }, + ); }); } - private async createDappClient() { - const { adapter: kvstore } = this.options.storage; - const sessionstore = new SessionStore(kvstore); - const websocket = typeof window !== 'undefined' ? WebSocket : (await import('ws')).WebSocket; - const transport = await WebSocketTransport.create({ url: MWP_RELAY_URL, kvstore, websocket }); - const dappClient = new DappClient({ transport, sessionstore, keymanager }); - return dappClient; + private async setupDefaultTransport() { + this.state = 'connecting'; + await this.storage.setTransport(TransportType.Browser); + const transport = new DefaultTransport(); + this.listener = transport.onNotification(this.onTransportNotification.bind(this)); + this.__transport = transport; + return transport; } - private async setupMWP() { - const { adapter: kvstore } = this.options.storage; - const dappClient = await this.createDappClient(); - this.__dappClient = dappClient; - - const apiTransport = new MWPTransport(dappClient, kvstore); - this.__transport = apiTransport; - - this.dappClient.once('connected', () => { - const apiClient = getMultichainClient({ transport: this.transport }); - this.__provider = apiClient; - this.listener = this.transport.onNotification(this.onTransportNotification.bind(this)); - this.options.storage.setTransport(TransportType.MPW); - this.options.ui.factory.unload(); + private async deeplinkConnect(scopes: Scope[], caipAccountIds: CaipAccountId[]) { + return new Promise((resolve, reject) => { + this.dappClient.on('message', (payload: any) => { + const data = payload.data as Record; + if (typeof data === 'object' && data !== null) { + if ('method' in data && data.method === 'wallet_createSession') { + if (data.error) { + this.state = 'loaded'; + return reject(data.error); + } + //TODO: is it .params or .result? + // biome-ignore lint/suspicious/noExplicitAny: Expected here + const session = ((data as any).params || (data as any).result) as SessionData; + if (session) { + // Initial request will be what resolves the connection when options is specified + this.options.transport?.onNotification?.(payload.data); + this.emit('wallet_sessionChanged', session); + } + } + } + }); + this.dappClient.once('session_request', (sessionRequest: SessionRequest) => { + const connectionRequest = { + sessionRequest, + metadata: { + dapp: this.options.dapp, + sdk: { + version: getVersion(), + platform: getPlatformType(), + }, + }, + }; + const deeplink = this.options.ui.factory.createDeeplink(connectionRequest); + const universalLink = this.options.ui.factory.createUniversalLink(connectionRequest); + if (this.options.mobile?.preferredOpenLink) { + this.options.mobile.preferredOpenLink(deeplink, '_self'); + } else { + this.openDeeplink(deeplink, universalLink); + } + }); + this.state = 'connecting'; + this.transport + .connect({ scopes, caipAccountIds }) + .then(() => { + this.state = 'connected'; + return resolve(); + }) + .catch(reject); }); } async connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise { - this.state = 'connecting'; const { ui } = this.options; const platformType = getPlatformType(); - const isWeb = platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.DesktopWeb || platformType === PlatformType.MobileWeb; + const isWeb = platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.DesktopWeb; const { preferExtension = true, preferDesktop = false, headless: _headless = false } = ui; if (this.__transport?.isConnected()) { - const existingSession = await this.getActiveSession(); - if (existingSession) { - return this.onConnectionSuccess({ scopes, caipAccountIds }); - } + return this.__transport.connect({ scopes, caipAccountIds }); } - //2. If is web, has extension and preferExtension is true, directly connect with the extension - if (isWeb && this.hasExtension && preferExtension) { - await this.storage.setTransport(TransportType.Browser); - const transport = await getDefaultTransport(this.options.transport); - this.listener = transport.onNotification(this.onTransportNotification.bind(this)); - this.__transport = transport; - this.__provider = getMultichainClient({ transport: this.__transport }); - return this.onConnectionSuccess({ scopes, caipAccountIds }); + if (isWeb && hasExtension() && preferExtension) { + //If metamask extension is available, connect to it + const defaultTransport = await this.setupDefaultTransport(); + // Web transport has no initial payload + return defaultTransport + .connect() + .then(() => { + this.state = 'connected'; + return Promise.resolve(); + }) + .catch((err) => { + this.state = 'loaded'; + return Promise.reject(err); + }); } + // Connection will now be InstallModal + QRCodes or Deeplinks, both require mwp + await this.setupMWP(); + // Determine preferred option for install modal - let preferredOption: boolean; + let isDesktopPreferred: boolean; if (isWeb) { - preferredOption = this.hasExtension ? preferDesktop : !preferExtension || preferDesktop; + isDesktopPreferred = hasExtension() ? preferDesktop : !preferExtension || preferDesktop; } else { - preferredOption = preferDesktop; + isDesktopPreferred = preferDesktop; + } + + const secure = isSecure(); + if (secure && !isDesktopPreferred) { + // Desktop is not preferred option, so we use deeplinks (mobile web) + return this.deeplinkConnect(scopes, caipAccountIds); } - return this.showInstallModal(preferredOption, scopes, caipAccountIds); + // Show install modal for RN, Web + Node + return this.showInstallModal(isDesktopPreferred, scopes, caipAccountIds); + } + + public override emit(event: string, args: any): void { + this.options.transport?.onNotification?.({ method: event, params: args }); + super.emit(event, args); } async disconnect(): Promise { @@ -359,6 +414,7 @@ export class MultichainSDK extends MultichainCore { await this.storage.removeTransport(); this.emit('wallet_sessionChanged', undefined); + this.emit('stateChanged', 'disconnected'); this.listener = undefined; this.__transport = undefined; @@ -366,10 +422,28 @@ export class MultichainSDK extends MultichainCore { this.__dappClient = undefined; } - async invokeMethod(options: InvokeMethodOptions): Promise { - 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}`; + async invokeMethod(request: InvokeMethodOptions): Promise { + const { + options: { + ui: { preferDesktop = false, headless: _headless = false }, + }, + sdkInfo, + transport, + } = this; + + this.__provider ??= getMultichainClient({ transport }); + const client = new RPCClient(this.provider, this.options.api, sdkInfo); - return client.invokeMethod(options); + const secure = isSecure(); + + if (secure && !preferDesktop) { + if (this.options.mobile?.preferredOpenLink) { + this.options.mobile.preferredOpenLink(METAMASK_DEEPLINK_BASE, '_self'); + } else { + this.openDeeplink(METAMASK_DEEPLINK_BASE, METAMASK_CONNECT_BASE_URL); + } + } + + return client.invokeMethod(request); } } diff --git a/packages/sdk-multichain/src/multichain/mwp/index.ts b/packages/sdk-multichain/src/multichain/mwp/index.ts deleted file mode 100644 index fcc5f7314..000000000 --- a/packages/sdk-multichain/src/multichain/mwp/index.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { TransportTimeoutError, type Transport, type TransportRequest, type TransportResponse } from '@metamask/multichain-api-client'; -import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; -import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; -import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; - -import type { StoreAdapter } from 'src/domain'; - -const DEFAULT_REQUEST_TIMEOUT = 10000; - -type PendingRequests = { - resolve: (value: TransportResponse) => void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; -}; - -/** - * Mobile Wallet Protocol transport implementation - * Bridges the MWP DappClient with the multichain API client Transport interface - */ -export class MWPTransport implements Transport { - private __reqId = 0; - private pendingRequests = new Map(); - private notificationCallbacks = new Set<(data: unknown) => void>(); - private currentSessionRequest: SessionRequest | undefined; - - private connectionPromise?: Promise; - - get sessionRequest() { - return this.currentSessionRequest; - } - - private notifyCallbacks(data: unknown): void { - this.notificationCallbacks.forEach((callback) => callback(data)); - } - - private rejectRequest(id: string, error = new Error('Request rejected')): void { - const request = this.pendingRequests.get(id); - if (request) { - this.pendingRequests.delete(id); - clearTimeout(request.timeout); - request.reject(error); - } - } - - private handleMessage(message: unknown): void { - if (typeof message === 'object' && message !== null && 'data' in message) { - const messagePayload = message.data as Record; - - if ('id' in messagePayload && typeof messagePayload.id === 'string') { - const request = this.pendingRequests.get(messagePayload.id); - if (request) { - clearTimeout(request.timeout); - request.resolve(messagePayload as TransportResponse); - this.pendingRequests.delete(messagePayload.id); - return; - } - } else { - this.notifyCallbacks(message); - } - } - } - - constructor( - private dappClient: DappClient, - private kvstore: StoreAdapter, - private options: Required<{ requestTimeout: number }> = { requestTimeout: DEFAULT_REQUEST_TIMEOUT }, - ) { - this.dappClient.on('message', this.handleMessage.bind(this)); - } - - /** - * Establishes a connection using the Mobile Wallet Protocol - * Note: This is a simplified implementation that expects the DappClient to be provided externally - */ - async connect(): Promise { - if (this.isConnected()) { - return; - } - - const { dappClient, kvstore } = this; - const sessionStore = new SessionStore(kvstore); - - this.connectionPromise ??= new Promise((resolve, reject) => { - sessionStore - .list() - .then(([activeSession]) => { - let connection: Promise; - if (activeSession) { - connection = dappClient.resume(activeSession.id); - } else { - connection = dappClient.connect({ mode: 'trusted' }); - } - connection.then(resolve).catch(reject); - }) - .catch(reject); - }); - - return this.connectionPromise.finally(() => { - this.connectionPromise = undefined; - }); - } - - /** - * Disconnects from the Mobile Wallet Protocol - */ - async disconnect(): Promise { - return this.dappClient.disconnect(); - } - - /** - * Checks if the transport is connected - */ - isConnected(): boolean { - // biome-ignore lint/suspicious/noExplicitAny: required if state is not made public in dappClient - return (this.dappClient as any).state === 'CONNECTED'; - } - - /** - * Sends a request through the Mobile Wallet Protocol - */ - async request(payload: TRequest): Promise { - const response = await new Promise((resolve, reject) => { - const request = { - id: `${this.__reqId++}`, - jsonrpc: '2.0', - ...payload, - }; - const timeout = setTimeout(() => { - this.rejectRequest(request.id, new TransportTimeoutError()); - }, this.options.requestTimeout); - this.pendingRequests.set(request.id, { resolve: resolve as (value: TransportResponse) => void, reject, timeout }); - this.dappClient.sendRequest(request); - }); - return response; - } - - /** - * Registers a callback for notifications from the wallet - */ - onNotification(callback: (data: unknown) => void): () => void { - this.notificationCallbacks.add(callback); - return () => { - this.notificationCallbacks.delete(callback); - }; - } -} diff --git a/packages/sdk-multichain/src/multichain/transports/default/index.ts b/packages/sdk-multichain/src/multichain/transports/default/index.ts new file mode 100644 index 000000000..35031893e --- /dev/null +++ b/packages/sdk-multichain/src/multichain/transports/default/index.ts @@ -0,0 +1,53 @@ +import { type CreateSessionParams, getDefaultTransport, type Transport, type TransportRequest, type TransportResponse } from '@metamask/multichain-api-client'; +import type { CaipAccountId } from '@metamask/utils'; +import type { ExtendedTransport, RPCAPI, Scope, SessionData } from 'src/domain'; +import { addValidAccounts, getOptionalScopes, getValidAccounts } from 'src/multichain/utils'; + +export class DefaultTransport implements ExtendedTransport { + #requestId = 0; + #transport: Transport = getDefaultTransport(); + + async connect(options?: { scopes: Scope[]; caipAccountIds: CaipAccountId[] }): Promise { + await this.#transport.connect(); + //Get wallet session + const sessionRequest = await this.request({ method: 'wallet_getSession' }); + let walletSession = sessionRequest.result as SessionData; + if (walletSession && options) { + const currentScopes = Object.keys(walletSession?.sessionScopes ?? {}) as Scope[]; + const proposedScopes = options?.scopes ?? []; + const isSameScopes = currentScopes.every((scope) => proposedScopes.includes(scope)) && proposedScopes.every((scope) => currentScopes.includes(scope)); + if (!isSameScopes) { + await this.request({ method: 'wallet_revokeSession', params: walletSession }); + const optionalScopes = addValidAccounts(getOptionalScopes(options?.scopes ?? []), getValidAccounts(options?.caipAccountIds ?? [])); + const sessionRequest: CreateSessionParams = { optionalScopes }; + const response = await this.request({ method: 'wallet_createSession', params: sessionRequest }); + walletSession = response.result as SessionData; + } + } else { + const optionalScopes = addValidAccounts(getOptionalScopes(options?.scopes ?? []), getValidAccounts(options?.caipAccountIds ?? [])); + const sessionRequest: CreateSessionParams = { optionalScopes }; + await this.request({ method: 'wallet_createSession', params: sessionRequest }); + } + } + + async disconnect(): Promise { + return this.#transport.disconnect(); + } + + isConnected() { + return this.#transport.isConnected(); + } + + async request(request: TRequest, options?: { timeout?: number }) { + const requestWithId = { + ...request, + jsonrpc: '2.0', + id: `${this.#requestId++}`, + }; + return this.#transport.request(requestWithId, options) as Promise; + } + + onNotification(callback: (data: unknown) => void) { + return this.#transport.onNotification(callback); + } +} diff --git a/packages/sdk-multichain/src/multichain/mwp/KeyManager.ts b/packages/sdk-multichain/src/multichain/transports/mwp/KeyManager.ts similarity index 100% rename from packages/sdk-multichain/src/multichain/mwp/KeyManager.ts rename to packages/sdk-multichain/src/multichain/transports/mwp/KeyManager.ts diff --git a/packages/sdk-multichain/src/multichain/transports/mwp/index.ts b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts new file mode 100644 index 000000000..5b542ff41 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/transports/mwp/index.ts @@ -0,0 +1,237 @@ +import { type CreateSessionParams, TransportTimeoutError, type TransportRequest, type TransportResponse } from '@metamask/multichain-api-client'; +import type { Session, SessionRequest } from '@metamask/mobile-wallet-protocol-core'; +import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; +import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; + +import type { ExtendedTransport, RPCAPI, Scope, SessionData, StoreAdapter } from '../../../domain'; +import type { CaipAccountId } from '@metamask/utils'; +import { addValidAccounts, getOptionalScopes, getValidAccounts } from '../../utils'; + +const DEFAULT_REQUEST_TIMEOUT = 60 * 1000; +const CONNECTION_GRACE_PERIOD = 60 * 1000; + +const DEFAULT_CONNECTION_TIMEOUT = DEFAULT_REQUEST_TIMEOUT + CONNECTION_GRACE_PERIOD; + +type PendingRequests = { + method: string; + resolve: (value: TransportResponse) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +}; + +/** + * Mobile Wallet Protocol transport implementation + * Bridges the MWP DappClient with the multichain API client Transport interface + */ +export class MWPTransport implements ExtendedTransport { + private __reqId = 0; + private __pendingRequests = new Map(); + private notificationCallbacks = new Set<(data: unknown) => void>(); + private currentSessionRequest: SessionRequest | undefined; + private connectionPromise?: Promise; + + get pendingRequests() { + return this.__pendingRequests; + } + + set pendingRequests(pendingRequests: Map) { + this.__pendingRequests = pendingRequests; + } + + get sessionRequest() { + return this.currentSessionRequest; + } + + constructor( + private dappClient: DappClient, + private kvstore: StoreAdapter, + private options: { requestTimeout: number; connectionTimeout: number } = { requestTimeout: DEFAULT_REQUEST_TIMEOUT, connectionTimeout: DEFAULT_CONNECTION_TIMEOUT }, + ) { + this.dappClient.on('message', this.handleMessage.bind(this)); + if (typeof window !== 'undefined') { + window.addEventListener('focus', this.onWindowFocus.bind(this)); + } + } + + private onWindowFocus(): void { + if (!this.isConnected()) { + this.dappClient.reconnect(); + } + } + + private notifyCallbacks(data: unknown): void { + this.notificationCallbacks.forEach((callback) => callback(data)); + } + + private rejectRequest(id: string, error = new Error('Request rejected')): void { + const request = this.pendingRequests.get(id); + if (request) { + this.pendingRequests.delete(id); + clearTimeout(request.timeout); + request.reject(error); + } + } + + private handleMessage(message: unknown): void { + if (typeof message === 'object' && message !== null) { + if ('data' in message) { + const messagePayload = message.data as Record; + if ('id' in messagePayload && typeof messagePayload.id === 'string') { + const request = this.pendingRequests.get(messagePayload.id); + if (request) { + const requestWithName = { + ...messagePayload, + method: request.method === 'wallet_getSession' || request.method === 'wallet_createSession' ? 'wallet_sessionChanged' : request.method, + } as unknown as TransportResponse; + clearTimeout(request.timeout); + request.resolve(requestWithName); + this.pendingRequests.delete(messagePayload.id); + return; + } + } else if ('name' in message && message.name === 'metamask-multichain-provider' && messagePayload.method === 'wallet_sessionChanged') { + this.notifyCallbacks(message.data); + return; + } + } + } + } + + private async onResumeSuccess(resumeResolve: () => void, resumeReject: (err: Error) => void, options?: { scopes: Scope[]; caipAccountIds: CaipAccountId[] }): Promise { + try { + const sessionRequest = await this.request({ method: 'wallet_getSession' }); + let walletSession = sessionRequest.result as SessionData; + if (options) { + const currentScopes = Object.keys(walletSession?.sessionScopes ?? {}) as Scope[]; + const proposedScopes = options?.scopes ?? []; + const isSameScopes = currentScopes.every((scope) => proposedScopes.includes(scope)) && proposedScopes.every((scope) => currentScopes.includes(scope)); + if (!isSameScopes) { + const optionalScopes = addValidAccounts(getOptionalScopes(options?.scopes ?? []), getValidAccounts(options?.caipAccountIds ?? [])); + const sessionRequest: CreateSessionParams = { optionalScopes }; + const response = await this.request({ method: 'wallet_createSession', params: sessionRequest }); + await this.request({ method: 'wallet_revokeSession', params: walletSession }); + walletSession = response.result as SessionData; + } + } + this.notifyCallbacks({ + method: 'wallet_sessionChanged', + params: walletSession, + }); + resumeResolve(); + } catch (err) { + resumeReject(err); + } + } + + /** + * Establishes a connection using the Mobile Wallet Protocol + * Note: This is a simplified implementation that expects the DappClient to be provided externally + */ + async connect(options?: { scopes: Scope[]; caipAccountIds: CaipAccountId[] }): Promise { + const { dappClient, kvstore } = this; + const sessionStore = new SessionStore(kvstore); + + let session: Session | undefined; + try { + const [activeSession] = await sessionStore.list(); + if (activeSession) { + session = activeSession; + } + } catch {} + + let timeout: NodeJS.Timeout; + this.connectionPromise ??= new Promise((resolve, reject) => { + let connection: Promise; + if (session) { + connection = new Promise((resumeResolve, resumeReject) => { + if (this.dappClient.state === 'CONNECTED') { + this.onResumeSuccess(resumeResolve, resumeReject, options); + } else { + this.dappClient.once('connected', async () => { + this.onResumeSuccess(resumeResolve, resumeReject, options); + }); + dappClient.resume(session.id); + } + }); + } else { + connection = new Promise((resolveConnection, rejectConnection) => { + this.dappClient.on('message', async (message: unknown) => { + if (typeof message === 'object' && message !== null) { + if ('data' in message) { + const messagePayload = message.data as Record; + if (messagePayload.method === 'wallet_createSession' || messagePayload.method === 'wallet_sessionChanged') { + if (messagePayload.error) { + return rejectConnection(messagePayload.error); + } + this.notifyCallbacks(message.data); + return resolveConnection(); + } + } + } + }); + const optionalScopes = addValidAccounts(getOptionalScopes(options?.scopes ?? []), getValidAccounts(options?.caipAccountIds ?? [])); + const sessionRequest: CreateSessionParams = { optionalScopes }; + const request = { jsonrpc: '2.0', id: `${this.__reqId++}`, method: 'wallet_createSession', params: sessionRequest }; + + dappClient.connect({ mode: 'trusted', initialPayload: request }).catch(rejectConnection); + }); + } + timeout = setTimeout(() => { + reject(new TransportTimeoutError()); + }, this.options.connectionTimeout); + connection.then(resolve).catch(reject); + }); + + return this.connectionPromise.finally(() => { + this.connectionPromise = undefined; + if (timeout) { + clearTimeout(timeout); + } + }); + } + + /** + * Disconnects from the Mobile Wallet Protocol + */ + async disconnect(): Promise { + return this.dappClient.disconnect(); + } + + /** + * Checks if the transport is connected + */ + isConnected(): boolean { + // biome-ignore lint/suspicious/noExplicitAny: required if state is not made public in dappClient + return (this.dappClient as any).state === 'CONNECTED'; + } + + /** + * Sends a request through the Mobile Wallet Protocol + */ + async request(payload: TRequest, options?: { timeout?: number }): Promise { + this.__reqId += 1; + const response = await new Promise((resolve, reject) => { + const request = { + jsonrpc: '2.0', + id: `${this.__reqId}`, + ...payload, + }; + const timeout = setTimeout(() => { + this.rejectRequest(request.id, new TransportTimeoutError()); + }, options?.timeout ?? this.options.requestTimeout); + + this.pendingRequests.set(request.id, { method: payload.method, resolve: resolve as (value: TransportResponse) => void, reject, timeout }); + this.dappClient.sendRequest(request).catch(reject); + }); + return response; + } + + /** + * Registers a callback for notifications from the wallet + */ + onNotification(callback: (data: unknown) => void): () => void { + this.notificationCallbacks.add(callback); + return () => { + this.notificationCallbacks.delete(callback); + }; + } +} diff --git a/packages/sdk-multichain/src/session.test.ts b/packages/sdk-multichain/src/session.test.ts index a6ec61241..9da35919d 100644 --- a/packages/sdk-multichain/src/session.test.ts +++ b/packages/sdk-multichain/src/session.test.ts @@ -3,11 +3,14 @@ import * as t from 'vitest'; import type { MultichainOptions, MultichainCore, Scope, SessionData } from './domain'; // Carefull, order of import matters to keep mocks working -import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, type MockedData, mockSessionData, type TestSuiteOptions } from './fixtures.test'; +import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobileEnv } from '../tests/fixtures.test'; -import * as loggerModule from './domain/logger'; import { Store } from './store'; +import type { TestSuiteOptions, MockedData } from '../tests/types'; +import { mockSessionData, mockSessionRequestData } from '../tests/data'; +import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; + function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { const { beforeEach, afterEach } = options; const originalSdkOptions = sdkOptions; @@ -19,8 +22,15 @@ function testSuite({ platform, createSDK, options: const transportString = platform === 'web' ? 'browser' : 'mwp'; t.beforeEach(async () => { + const uiOptions: MultichainOptions['ui'] = + platform === 'web-mobile' + ? { + ...originalSdkOptions.ui, + preferDesktop: false, + preferExtension: false, + } + : originalSdkOptions.ui; 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 = { @@ -30,6 +40,8 @@ function testSuite({ platform, createSDK, options: enabled: platform !== 'node', integrationType: 'test', }, + ui: uiOptions, + storage: new Store({ platform: platform as 'web' | 'rn' | 'node', get(key) { @@ -52,9 +64,6 @@ function testSuite({ platform, createSDK, options: t.it(`${platform} should handle session upgrades`, async () => { const scopes = ['eip155:1', 'eip155:137'] as Scope[]; const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678', 'eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; const mockedSessionUpgradeData: SessionData = { ...mockSessionData, sessionScopes: { @@ -66,115 +75,143 @@ function testSuite({ platform, createSDK, options: }, }, }; - mockMultichainClient.getSession.mockResolvedValue(mockSessionData); - - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockSessionData, - }); - } - - if (input.method === 'wallet_createSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: mockedSessionUpgradeData, - }); - } + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockedSessionUpgradeData); - if (input.method === 'wallet_revokeSession') { - return Promise.resolve({ id: 1, jsonrpc: '2.0', result: mockSessionData }); - } - return Promise.reject(new Error('Forgot to mock this RPC call?')); - }); + t.vi.spyOn(SessionStore.prototype, 'list').mockImplementation(async () => Promise.resolve([await (mockedData as any).mockWalletGetSession()])); sdk = await createSDK(testOptions); - (mockedData.mockTransport as any)._isConnected = true; - // Mock createSession to return the upgraded session data - mockMultichainClient.createSession.mockResolvedValue({ data: mockedSessionUpgradeData }); - - await sdk.connect(scopes, caipAccountIds); + t.expect(sdk.state).toBe('connected'); + t.expect(sdk.transport).toBeDefined(); + t.expect(() => sdk.provider).toThrow(); + t.expect(sdk.storage).toBeDefined(); + await t.expect(sdk.storage.getTransport()).resolves.toBe(transportString); - t.expect(mockedData.mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_getSession', - }); + mockedData.mockDefaultTransport.request.mockClear(); + mockedData.mockDappClient.sendRequest.mockClear(); - t.expect(mockedData.mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_revokeSession', - params: mockSessionData, - }); + await sdk.connect(scopes, caipAccountIds); - mockedData.mockTransport.__triggerNotification({ - method: 'wallet_sessionChanged', - params: { - session: mockedSessionUpgradeData, - }, - }); - // sessionChanged should be emitted with the full session data returned from createSession, not just the scopes - t.expect(mockedData.emitSpy).toHaveBeenCalledWith('wallet_sessionChanged', mockedSessionUpgradeData); + if (platform === 'web') { + t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( + t.expect.objectContaining({ + jsonrpc: '2.0', + method: 'wallet_getSession', + }), + + undefined, + ); + t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( + t.expect.objectContaining({ + method: 'wallet_revokeSession', + params: mockSessionData, + }), + undefined, + ); + + t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( + t.expect.objectContaining({ + method: 'wallet_createSession', + params: { + optionalScopes: mockedSessionUpgradeData.sessionScopes, + }, + }), + undefined, + ); + } else { + t.expect(mockedData.mockDappClient.sendRequest).toHaveBeenCalledWith( + t.expect.objectContaining({ + method: 'wallet_getSession', + }), + ); + t.expect(mockedData.mockDappClient.sendRequest).toHaveBeenCalledWith( + t.expect.objectContaining({ + method: 'wallet_revokeSession', + params: mockSessionData, + }), + ); + t.expect(mockedData.mockDappClient.sendRequest).toHaveBeenCalledWith( + t.expect.objectContaining({ + method: 'wallet_createSession', + params: { + optionalScopes: mockedSessionUpgradeData.sessionScopes, + }, + }), + ); + } }); 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; + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - mockedData.mockTransport.request.mockImplementation((input: any) => { - if (input.method === 'wallet_getSession') { - return Promise.resolve({ - id: 1, - jsonrpc: '2.0', - result: undefined, - }); - } - - return Promise.reject(new Error('Forgot to mock this RPC call?')); - }); - - // Mock no session scenario - mockMultichainClient.getSession.mockResolvedValue(undefined); + mockedData.nativeStorageStub.setItem('multichain-transport', transportString); + mockedData.mockWalletGetSession.mockImplementation(() => undefined as any); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); sdk = await createSDK(testOptions); + t.expect(sdk.state).toBe('connected'); 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(mockedData.mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_getSession', - }); + + await sdk.connect(scopes, caipAccountIds); + + /** + * We expect the default transport to be called when on web and mobile wallet protocol for the rest + */ + if (platform === 'web') { + t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( + t.expect.objectContaining({ + jsonrpc: '2.0', + method: 'wallet_getSession', + }), + undefined, + ); + t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( + t.expect.objectContaining({ + method: 'wallet_createSession', + params: { + optionalScopes: mockSessionData.sessionScopes, + }, + }), + undefined, + ); + } else { + t.expect(mockedData.mockDappClient.sendRequest).toHaveBeenCalledWith( + t.expect.objectContaining({ + method: 'wallet_createSession', + params: { + optionalScopes: {}, + }, + }), + ); + } }); t.it(`${platform} should handle provider errors during session retrieval`, async () => { + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; + // 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(); - - mockedData.mockTransport.request.mockImplementation(() => { - return Promise.reject(sessionError); - }); - mockMultichainClient.getSession.mockRejectedValue(sessionError); + mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); + mockedData.mockWalletCreateSession.mockRejectedValue(sessionError); + mockedData.mockWalletGetSession.mockRejectedValue(sessionError); sdk = await createSDK(testOptions); t.expect(sdk).toBeDefined(); - t.expect(sdk.state === 'pending').toBe(true); + t.expect(sdk.state === 'loaded').toBe(true); - // Access the mock logger from the module - const mockLogger = (loggerModule as any).__mockLogger; + await t.expect(() => sdk.connect(scopes, caipAccountIds)).rejects.toThrow(sessionError); - // Verify that the logger was called with the error - t.expect(mockLogger).toHaveBeenCalledWith('MetaMaskSDK error during initialization', sessionError); + t.expect(sdk.state === 'loaded').toBe(true); }); }); } @@ -186,3 +223,4 @@ const baseTestOptions = { dapp: exampleDapp } as any; runTestsInNodeEnv(baseTestOptions, testSuite); runTestsInRNEnv(baseTestOptions, testSuite); runTestsInWebEnv(baseTestOptions, testSuite, exampleDapp.url); +runTestsInWebMobileEnv(baseTestOptions, testSuite, exampleDapp.url); diff --git a/packages/sdk-multichain/src/ui/index.test.ts b/packages/sdk-multichain/src/ui/index.test.ts index e08a2df96..dbe761012 100644 --- a/packages/sdk-multichain/src/ui/index.test.ts +++ b/packages/sdk-multichain/src/ui/index.test.ts @@ -30,7 +30,7 @@ t.vi.mock('../domain', async () => { }); t.describe('ModalFactory', () => { - let mockModal: Modal | Modal; + let mockModal: Modal | Modal; let mockModalOptions: t.Mock<() => InstallWidgetProps | OTPCodeWidgetProps>; let mockData: t.Mock<() => QRLink | OTPCode>; @@ -220,6 +220,7 @@ t.describe('ModalFactory', () => { }, }; uiModule = new ModalFactory(mockFactoryOptions); + //uiModule.modal = mockModal; mockContainer = document.createElement('div'); }); @@ -231,7 +232,7 @@ t.describe('ModalFactory', () => { await uiModule.renderInstallModal( preferDesktop, () => Promise.resolve(connectionRequest), - () => {}, + async () => {}, ); t.expect(document.body.contains(mockContainer)).toBe(true); @@ -285,7 +286,7 @@ t.describe('ModalFactory', () => { const preferDesktop = true; t.vi.spyOn(uiModule as any, 'getContainer').mockReturnValue(mockContainer); - await uiModule.renderInstallModal(preferDesktop, createSessionRequestMock, () => {}); + await uiModule.renderInstallModal(preferDesktop, createSessionRequestMock, async () => {}); t.expect(mockModal.mount).toHaveBeenCalled(); @@ -321,14 +322,15 @@ t.describe('ModalFactory', () => { await uiModule.renderInstallModal( false, () => Promise.resolve(connectionRequest), - () => {}, + async () => {}, ); const constructorArgs = (mockFactoryOptions.InstallModal as any).mock.calls[0][0]; - constructorArgs.onClose(); + await constructorArgs.onClose(); - t.expect(mockModal.unmount).toHaveBeenCalled(); + // Multichain SDK is what will close the modal instead + t.expect(mockModal.unmount).not.toHaveBeenCalled(); }); t.it('should handle desktop onboarding correctly', async () => { @@ -355,10 +357,11 @@ t.describe('ModalFactory', () => { await uiModule.renderInstallModal( false, () => Promise.resolve(connectionRequest), - () => {}, + async () => {}, ); const constructorArgs = (mockFactoryOptions.InstallModal as any).mock.calls[0][0]; + constructorArgs.startDesktopOnboarding(); t.expect(mockModal.unmount).not.toHaveBeenCalled(); @@ -369,7 +372,7 @@ t.describe('ModalFactory', () => { t.it('should render OTP code modal with placeholder props', async () => { await uiModule.renderOTPCodeModal( () => Promise.resolve('123456' as OTPCode), - () => {}, + async () => {}, () => {}, ); @@ -419,7 +422,7 @@ t.describe('ModalFactory', () => { uiModule.renderInstallModal( false, () => Promise.resolve(connectionRequest), - () => {}, + async () => {}, ), ) .rejects.toThrow('Render failed'); @@ -457,7 +460,7 @@ t.describe('ModalFactory', () => { await uiModule.renderInstallModal( false, () => Promise.resolve(connectionRequest), - () => {}, + async () => {}, ); const firstModal = mockModal; @@ -471,7 +474,7 @@ t.describe('ModalFactory', () => { // Render second modal await uiModule.renderOTPCodeModal( () => Promise.resolve('123456' as OTPCode), - () => {}, + async () => {}, () => {}, ); diff --git a/packages/sdk-multichain/src/ui/index.ts b/packages/sdk-multichain/src/ui/index.ts index 653b38e84..b079c3aae 100644 --- a/packages/sdk-multichain/src/ui/index.ts +++ b/packages/sdk-multichain/src/ui/index.ts @@ -2,6 +2,7 @@ import MetaMaskOnboarding from '@metamask/onboarding'; import { type ConnectionRequest, getPlatformType, getVersion, type Modal, type OTPCode, PlatformType } from '../domain'; import type { FactoryModals, ModalTypes } from './modals/types'; import type { AbstractOTPCodeModal } from './modals/base/AbstractOTPModal'; +import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from 'src/config'; // @ts-ignore let __instance: typeof import('@metamask/sdk-multichain-ui/dist/loader/index.cjs.js') | undefined; @@ -23,9 +24,10 @@ export async function preload() { } export class ModalFactory { + // biome-ignore lint/suspicious/noExplicitAny: Expected here public modal!: Modal; private readonly platform: PlatformType = getPlatformType(); - private successCallback!: (error?: Error) => void; + private successCallback!: (error?: Error) => Promise; /** * Creates a new modal factory instance. @@ -43,9 +45,8 @@ export class ModalFactory { } } - unload(error?: Error) { - this.modal?.unmount(); - this.successCallback?.(error); + async unload(error?: Error) { + await this.successCallback?.(error); } /** @@ -87,28 +88,34 @@ export class ModalFactory { return container; } - private async generateQRCode(connectionRequest: ConnectionRequest) { + createDeeplink(connectionRequest: ConnectionRequest) { + const json = JSON.stringify(connectionRequest); + const urlEncoded = encodeURIComponent(json); + return `${METAMASK_DEEPLINK_BASE}/mwp?p=${urlEncoded}`; + } + + createUniversalLink(connectionRequest: ConnectionRequest) { const json = JSON.stringify(connectionRequest); const urlEncoded = encodeURIComponent(json); - return `metamask://connect/mwp?p=${urlEncoded}`; + return `${METAMASK_CONNECT_BASE_URL}/mwp?p=${urlEncoded}`; } - private onCloseModal() { - this.unload(new Error('User closed modal')); + private async onCloseModal(shouldTerminate = true) { + return this.unload(shouldTerminate ? new Error('User closed modal') : undefined); } private onStartDesktopOnboarding() { new MetaMaskOnboarding().startOnboarding(); } - public async renderInstallModal(preferDesktop: boolean, createConnectionRequest: () => Promise, successCallback: (error?: Error) => void) { + public async renderInstallModal(preferDesktop: boolean, createConnectionRequest: () => Promise, successCallback: (error?: Error) => Promise) { this.modal?.unmount(); await preload(); this.successCallback = successCallback; const parentElement = this.getMountedContainer(); const connectionRequest = await createConnectionRequest(); - const qrCodeLink = await this.generateQRCode(connectionRequest); + const qrCodeLink = this.createDeeplink(connectionRequest); const modal = new this.options.InstallModal({ expiresIn: (connectionRequest.sessionRequest.expiresAt - Date.now()) / 1000, @@ -117,7 +124,7 @@ export class ModalFactory { preferDesktop, link: qrCodeLink, sdkVersion: getVersion(), - generateQRCode: this.generateQRCode.bind(this), + generateQRCode: async (request) => this.createDeeplink(request), onClose: this.onCloseModal.bind(this), startDesktopOnboarding: this.onStartDesktopOnboarding.bind(this), createConnectionRequest, @@ -129,7 +136,7 @@ export class ModalFactory { public async renderOTPCodeModal( createOTPCode: () => Promise, - successCallback: (error?: Error) => void, + successCallback: (error?: Error) => Promise, updateOTPCode: (otpCode: OTPCode, modal: AbstractOTPCodeModal) => void, ) { this.modal?.unmount(); diff --git a/packages/sdk-multichain/src/ui/modals/base/AbstractInstallModal.test.ts b/packages/sdk-multichain/src/ui/modals/base/AbstractInstallModal.test.ts index 3659ebc7a..e6aa53564 100644 --- a/packages/sdk-multichain/src/ui/modals/base/AbstractInstallModal.test.ts +++ b/packages/sdk-multichain/src/ui/modals/base/AbstractInstallModal.test.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: ok in tests */ import * as t from 'vitest'; import type { ConnectionRequest, QRLink } from '../../../domain'; diff --git a/packages/sdk-multichain/src/ui/modals/node/index.test.ts b/packages/sdk-multichain/src/ui/modals/node/index.test.ts index 22199830c..e42cd64f0 100644 --- a/packages/sdk-multichain/src/ui/modals/node/index.test.ts +++ b/packages/sdk-multichain/src/ui/modals/node/index.test.ts @@ -18,7 +18,7 @@ vi.mock('@paulmillr/qr', () => { t.describe('Node Modals', () => { let connectionRequest: ConnectionRequest; - let modal: Modal | undefined; + let modal: Modal | undefined; let consoleLogSpy: any; t.beforeAll(() => { diff --git a/packages/sdk-multichain/src/ui/modals/rn/index.test.ts b/packages/sdk-multichain/src/ui/modals/rn/index.test.ts index 7abd76eb8..c9b3ac420 100644 --- a/packages/sdk-multichain/src/ui/modals/rn/index.test.ts +++ b/packages/sdk-multichain/src/ui/modals/rn/index.test.ts @@ -8,7 +8,7 @@ import { v4 } from 'uuid'; t.describe('RN Modals', () => { let connectionRequest: ConnectionRequest; - let modal: Modal | undefined; + let modal: Modal | undefined; t.beforeEach(() => { connectionRequest = { diff --git a/packages/sdk-multichain/src/ui/modals/web/index.test.ts b/packages/sdk-multichain/src/ui/modals/web/index.test.ts index 53bda96d0..24b790c8a 100644 --- a/packages/sdk-multichain/src/ui/modals/web/index.test.ts +++ b/packages/sdk-multichain/src/ui/modals/web/index.test.ts @@ -20,7 +20,7 @@ class CustomEvent extends dom.window.Event { } t.describe('WEB Modals', () => { - let modal: Modal | undefined; + let modal: Modal | undefined; let connectionRequest: ConnectionRequest; t.beforeAll(() => { @@ -124,10 +124,9 @@ t.describe('WEB Modals', () => { t.it('should handle close event', () => { installModal.mount(); const modalElement = document.querySelector('mm-install-modal'); - modalElement?.addEventListener('close', (event: any) => { - onClose(event.detail.shouldTerminate); - }); - modalElement?.dispatchEvent(new CustomEvent('close', { detail: { shouldTerminate: true } })); + modalElement?.addEventListener('close', onClose); + modalElement?.dispatchEvent(new CustomEvent('close', { detail: { shouldTerminate: true } }) as any); + t.expect(onClose).toHaveBeenCalledWith(new CustomEvent('close', { detail: { shouldTerminate: true } })); }); t.it('should handle startDesktopOnboarding event', () => { @@ -136,7 +135,7 @@ t.describe('WEB Modals', () => { modalElement?.addEventListener('startDesktopOnboarding', () => { onStartDesktopOnboarding(); }); - modalElement?.dispatchEvent(new CustomEvent('startDesktopOnboarding')); + modalElement?.dispatchEvent(new CustomEvent('startDesktopOnboarding') as any); }); }); diff --git a/packages/sdk-multichain/tests/data.ts b/packages/sdk-multichain/tests/data.ts new file mode 100644 index 000000000..b28590942 --- /dev/null +++ b/packages/sdk-multichain/tests/data.ts @@ -0,0 +1,22 @@ +import { SessionRequest } from "@metamask/mobile-wallet-protocol-core"; +import { SessionData } from "@metamask/multichain-api-client"; + +// Mock session data for testing +export const mockSessionData: SessionData = { + sessionScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + methods: [], + notifications: [], + }, + }, + expiry: new Date(Date.now() + 3600000).toISOString(), +}; + +export const mockSessionRequestData: SessionRequest = { + id: '1', + mode: 'trusted', + channel: 'eip155:1', + publicKeyB64: '1234567890', + expiresAt: new Date(Date.now() + 3600000).getTime(), +}; diff --git a/packages/sdk-multichain/tests/env/index.ts b/packages/sdk-multichain/tests/env/index.ts new file mode 100644 index 000000000..e6ca18a78 --- /dev/null +++ b/packages/sdk-multichain/tests/env/index.ts @@ -0,0 +1,164 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/suspicious/noAsyncPromiseExecutor: ok for tests */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ +import { vi } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { JSDOM as Page } from 'jsdom'; +import { NativeStorageStub } from 'tests/types'; +import * as t from 'vitest'; +import * as nodeStorage from '../../src/store/adapters/node'; +import * as webStorage from '../../src/store/adapters/web'; + +export const TRANSPORT_REQUEST_RESPONSE_DELAY = 25; + +/** + * Virtualize nodejs environments, mocking everything needed to run the tests in Node + */ +export const setupNodeMocks = (nativeStorageStub: NativeStorageStub) => { + // Mock console.log to prevent QR codes from displaying in test output + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'clear').mockImplementation(() => {}); + + vi.spyOn(nodeStorage, 'StoreAdapterNode').mockImplementation(() => { + const __storage = { + get: t.vi.fn((key: string) => nativeStorageStub.getItem(key)), + set: t.vi.fn((key: string, value: string) => nativeStorageStub.setItem(key, value)), + delete: t.vi.fn((key: string) => nativeStorageStub.removeItem(key)), + platform: 'node' as const, + get storage() { + return __storage; + }, + } as any; + return __storage; + }); +}; + +/** + * Virtualize nodejs environments, mocking everything needed to run the tests in React Native + */ +export const setupRNMocks = (nativeStorageStub: NativeStorageStub) => { + // Mock console.log to prevent QR codes from displaying in test output (for consistency) + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'clear').mockImplementation(() => {}); + + vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key)); + vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)); + vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.removeItem(key)); +}; + +/** + * Virtualize wev environments, mocking everything needed to run the tests in Web with Chrome extension available + */ +export const setupWebMocks = (nativeStorageStub: NativeStorageStub, dappUrl = 'https://test.dapp') => { + const dom = new Page('

Hello world

', { + url: dappUrl, + }); + + // Properly bind event methods to maintain context + const globalStub = { + ...dom.window, + addEventListener: dom.window.addEventListener.bind(dom.window), + removeEventListener: dom.window.removeEventListener.bind(dom.window), + dispatchEvent: dom.window.dispatchEvent.bind(dom.window), + ethereum: { + isMetaMask: true, + } + }; + vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', + language: 'en-US', + }); + vi.stubGlobal('window', globalStub); + vi.stubGlobal('location', dom.window.location); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('Event', dom.window.Event); + vi.stubGlobal('requestAnimationFrame', t.vi.fn()); + vi.stubGlobal('dispatchEvent', dom.window.dispatchEvent.bind(dom.window)); + vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { + const __storage = { + get: t.vi.fn((key: string) => { + return nativeStorageStub.getItem(key); + }), + set: t.vi.fn((key: string, value: string) => { + return nativeStorageStub.setItem(key, value); + }), + delete: t.vi.fn((key: string) => { + return nativeStorageStub.removeItem(key); + }), + platform: 'web' as const, + get storage() { + return __storage; + }, + } as any; + return __storage; + }); +}; + + + +/** + * Virtualize wev environments, mocking everything needed to run the tests in Web with Chrome extension available + */ +export const setupWebMobileMocks = (nativeStorageStub: NativeStorageStub, dappUrl = 'https://test.dapp') => { + const dom = new Page('

Hello world

', { + url: dappUrl, + }); + + const navigator = { + ...dom.window.navigator, + product: 'Chrome', + language: 'en-US', + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', + } + + // Mock location with proper href setter to avoid JSDOM navigation errors + const mockLocation = { + ...dom.window.location, + set href(value: string) { + // Mock the href setter to avoid JSDOM navigation errors + // In tests, we just track that it was called instead of actually navigating + }, + }; + + // Properly bind event methods to maintain context + const globalStub = { + ...dom.window, + addEventListener: dom.window.addEventListener.bind(dom.window), + removeEventListener: dom.window.removeEventListener.bind(dom.window), + dispatchEvent: dom.window.dispatchEvent.bind(dom.window), + ethereum: { + isMetaMask: true, + }, + location: mockLocation, + navigator + }; + + vi.stubGlobal('navigator', navigator); + vi.stubGlobal('window', globalStub); + vi.stubGlobal('location', mockLocation); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('Event', dom.window.Event); + vi.stubGlobal('requestAnimationFrame', t.vi.fn()); + vi.stubGlobal('dispatchEvent', dom.window.dispatchEvent.bind(dom.window)); + vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { + const __storage = { + get: t.vi.fn((key: string) => { + return nativeStorageStub.getItem(key); + }), + set: t.vi.fn((key: string, value: string) => { + return nativeStorageStub.setItem(key, value); + }), + delete: t.vi.fn((key: string) => { + return nativeStorageStub.removeItem(key); + }), + platform: 'web' as const, + get storage() { + return __storage; + }, + } as any; + return __storage; + }); +}; diff --git a/packages/sdk-multichain/tests/fixtures.test.ts b/packages/sdk-multichain/tests/fixtures.test.ts new file mode 100644 index 000000000..a7f8f826d --- /dev/null +++ b/packages/sdk-multichain/tests/fixtures.test.ts @@ -0,0 +1,517 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */ +/** biome-ignore-all lint/suspicious/noAsyncPromiseExecutor: ok for tests */ +/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */ + +/** + * Fixtures files, allows us to create a standardized test configuration for each platform + * Allows us to run the tests in Node, React Native and Web without changing the overall logic or having to add manual testing + * + * We first have mocks in tests/mocks/index.ts, in that file we declare all the mocked packages and data that is needed to run the tests + * + * We also have tests/env/index.ts, that file contains everything that is needed to virtualize the environment for each platform, mocking window object, + * and whatever else is required by the platform + * + * Finally we have the createTest function, that is used to create the test configuration for each platform, and the runTestsInNodeEnv, runTestsInRNEnv and runTestsInWebEnv functions, + * that are used to run the tests for each platform. + */ + +import './mocks'; +import * as t from 'vitest'; +import { vi } from 'vitest'; +import type { MultichainOptions } from '../src/domain'; +import { MultichainSDK } from '../src/multichain'; + +// Import createSDK functions for convenience +import { createMetamaskSDK as createMetamaskSDKWeb } from '../src/index.browser'; +import { createMetamaskSDK as createMetamaskSDKRN } from '../src/index.native'; +import { createMetamaskSDK as createMetamaskSDKNode } from '../src/index.node'; +import type { NativeStorageStub, MockedData, TestSuiteOptions, CreateTestFN } from '../tests/types'; + +import { getDefaultTransport, TransportResponse } from '@metamask/multichain-api-client'; +import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; + +import { setupNodeMocks, setupRNMocks, setupWebMobileMocks, setupWebMocks } from './env'; +import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; + +export const TRANSPORT_REQUEST_RESPONSE_DELAY = 50; + + +// Helper functions to create standardized test configurations +export const runTestsInNodeEnv = (options: T, testSuite: (options: TestSuiteOptions) => void) => { + return createTest({ + platform: 'node', + createSDK: createMetamaskSDKNode, + options, + setupMocks: setupNodeMocks, + tests: testSuite, + }); +}; + +export const runTestsInRNEnv = (options: T, testSuite: (options: TestSuiteOptions) => void) => { + return createTest({ + platform: 'rn', + createSDK: createMetamaskSDKRN, + options, + setupMocks: setupRNMocks, + tests: testSuite, + }); +}; + +export const runTestsInWebEnv = (options: T, testSuite: (options: TestSuiteOptions) => void, dappUrl?: string) => { + return createTest({ + platform: 'web', + createSDK: createMetamaskSDKWeb, + options, + setupMocks: (nativeStorageStub) => setupWebMocks(nativeStorageStub, dappUrl), + tests: testSuite, + }); +}; + +export const runTestsInWebMobileEnv = (options: T, testSuite: (options: TestSuiteOptions) => void, dappUrl?: string) => { + return createTest({ + platform: 'web-mobile', + createSDK: createMetamaskSDKWeb, + options, + setupMocks: (nativeStorageStub) => setupWebMobileMocks(nativeStorageStub, dappUrl), + tests: testSuite, + }); +}; +export const createTest: CreateTestFN = ({ platform, options, createSDK, setupMocks, cleanupMocks, tests }) => { + const mockWalletGetSession = t.vi.fn(() => Promise.reject('Please mock mockWalletGetSession')) as any; + const mockWalletCreateSession = t.vi.fn(() => Promise.reject('Please mock mockWalletCreateSession')) as any; + const mockSessionRequest = t.vi.fn(() => Promise.reject('Please mock mockSessionRequest')) as any; + const mockWalletInvokeMethod = t.vi.fn(() => Promise.reject('Please mock mockWalletInvokeMethod')) as any; + const mockWalletRevokeSession = t.vi.fn() as any; + + const nativeStorageStub: 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(); + }), + } + let setupAnalyticsSpy!: t.MockInstance; + let initSpy!: t.MockInstance; + let emitSpy!: t.MockInstance; + let showInstallModalSpy!: t.MockInstance; + let mockLogger!: t.MockInstance; + let mockDefaultTransport!: t.Mocked; + + let pendingRequests:Map) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + }> + + async function beforeEach() { + try { + pendingRequests = new Map(); + + nativeStorageStub.data.clear(); + + const defaultTransportMock = t.vi.mocked(getDefaultTransport); + const createDappClientMock = t.vi.mocked(DappClient); + + const loggerActual = (await import('../src/domain/logger')) as any; + const mwpCoreActual = (await import('@metamask/mobile-wallet-protocol-core')) as any; + const mwpTransportActual = (await import('../src/multichain/transports/mwp')) as any; + + mockLogger = loggerActual.__mockLogger; + mwpTransportActual.__mockPendingRequestsMap = pendingRequests; + + let requestId = 0; + mockDefaultTransport = { + isDefaultTransport: true, + connect: t.vi.fn(async () => { + mockDefaultTransport.__isConnected = true; + }), + disconnect: t.vi.fn(() => { + mockDefaultTransport.__isConnected = false; + }), + isConnected: t.vi.fn(() => { + return mockDefaultTransport.__isConnected; + }), + request: t.vi.fn(async (payload) => { + try { + const id = `${payload.id ?? requestId++}`; + + if (payload.method === 'wallet_getSession') { + const result = await mockWalletGetSession(); + return Promise.resolve({ + id, + jsonrpc: '2.0', + method: 'wallet_getSession', + result, + }); + } + + if (payload.method === 'wallet_createSession') { + const result = await mockWalletCreateSession(); + return Promise.resolve({ + id, + jsonrpc: '2.0', + method: 'wallet_createSession', + result, + }); + } + if (payload.method === 'wallet_revokeSession') { + const result = await mockWalletRevokeSession(); + return Promise.resolve({ + id, + jsonrpc: '2.0', + method: 'wallet_revokeSession', + result, + }); + } + if (payload.method === 'wallet_invokeMethod') { + const result = await mockWalletInvokeMethod(); + return Promise.resolve({ + id, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + result, + }); + } + + return Promise.reject(new Error(`Forgot to mock ${payload.method} RPC call?`)); + } catch (err) { + return Promise.reject(err); + } + }), + onNotification: t.vi.fn((callback: (data: any) => void) => { + mockDefaultTransport.__notificationCallback = callback; + return () => { + mockDefaultTransport.__notificationCallback = null; + }; + }), + __isConnected: false, + __notificationCallback: null as ((data: any) => void) | null, + __triggerNotification: t.vi.fn((data: any) => { + if (mockDefaultTransport.__notificationCallback) { + mockDefaultTransport.__notificationCallback(data); + } + }), + }; + + // 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'); + showInstallModalSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'showInstallModal'); + + + mwpCoreActual.__mockStorage = nativeStorageStub.data; + + const eventListeners = new Map void; once: boolean }>>(); + const mockDappClient = { + __state: 'DISCONNECTED' as any, + get state() { + return mockDappClient.__state; + }, + set state(state: string) { + mockDappClient.__state = state; + }, + reconnect:t.vi.fn(async() => { + mockDappClient.emit('connected'); + mockDappClient.state = 'CONNECTED' as any; + return Promise.resolve(); + }), + resume:t.vi.fn(async() => { + mockDappClient.emit('connected'); + mockDappClient.state = 'CONNECTED' as any; + await mockDappClient.sendRequest({ id: `${this.__reqId++}`, jsonrpc: '2.0', method: 'wallet_getSession', params: [] }); + return Promise.resolve(); + }), + connect:t.vi.fn(async (data: any) => { + //Establish the connection automatically + mockDappClient.emit('connected'); + (mockDappClient as any).state = 'CONNECTED' as any; + + //Send session request for mwp + const sessionRequest = mockSessionRequest(); + mockDappClient.emit('session_request', sessionRequest); + + if (data?.initialPayload) { + await mockDappClient.sendRequest(data.initialPayload); + } + }), + disconnect:t.vi.fn(async () => { + mockDappClient.emit('disconnected'); + mockDappClient.state = 'DISCONNECTED' as any; + return Promise.resolve(); + }), + sendRequest:t.vi.fn(async (request: any) => { + try { + const id = `${request.id ?? requestId++}`; + if (request.method === 'wallet_getSession') { + return new Promise((resolve, reject) => { + const req = { + resolve: (result: any) => { + mockDappClient.emit('message', { + data: { + id, + jsonrpc: '2.0', + method: 'wallet_getSession', + result, + }, + }); + resolve(); + }, + reject: reject, timeout: null as any + } + pendingRequests.set(id,req ); + setTimeout(async () => { + try { + const result =await mockWalletGetSession(); + req.resolve(result); + } catch (err) { + req.reject(err) + } + }, TRANSPORT_REQUEST_RESPONSE_DELAY); + }); + } + if (request.method === 'wallet_createSession') { + + return new Promise((resolve, reject) => { + const req = { + resolve: (result: any) => { + mockDappClient.emit('message', { + data: { + id, + jsonrpc: '2.0', + method: 'wallet_createSession', + result, + }, + }); + resolve(); + }, + reject: (err) => { + mockDappClient.emit('message', { + data: { + id, + jsonrpc: '2.0', + method: 'wallet_createSession', + error: err, + }, + }); + resolve() + }, timeout: null as any + } + pendingRequests.set(id,req ); + setTimeout(async () => { + try { + const result =await mockWalletCreateSession(); + req.resolve(result); + } catch (err) { + req.reject(err) + } + }, TRANSPORT_REQUEST_RESPONSE_DELAY); + }); + } + + if (request.method === 'wallet_revokeSession') { + return new Promise((resolve, reject) => { + const req = { + resolve: (result: any) => { + mockDappClient.emit('message', { + data: { + id, + jsonrpc: '2.0', + method: 'wallet_revokeSession', + result, + }, + }); + resolve(); + }, + reject: reject, timeout: null as any + } + pendingRequests.set(id,req ); + setTimeout(async () => { + try { + const result =await mockWalletRevokeSession(); + req.resolve(result); + } catch (err) { + req.reject(err) + } + }, TRANSPORT_REQUEST_RESPONSE_DELAY); + }); + } + + if (request.method === 'wallet_invokeMethod') { + return new Promise((resolve, reject) => { + const req = { + resolve: (result: any) => { + mockDappClient.emit('message', { + data: { + id, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + result, + }, + }); + resolve(); + }, + reject: reject, timeout: null as any + } + pendingRequests.set(id,req ); + setTimeout(async () => { + try { + const result =await mockWalletInvokeMethod(); + req.resolve(result); + } catch (err) { + req.reject(err) + } + }, TRANSPORT_REQUEST_RESPONSE_DELAY); + }); + } + + return Promise.reject(new Error('Forgot to mock this RPC call?')); + } catch (err) { + throw err; + } + }), + + // Event handling methods + once:t.vi.fn((event: string, handler: (...args: any[]) => void) => { + if (!eventListeners.has(event)) { + eventListeners.set(event, []); + } + eventListeners.get(event)!.push({ handler, once: true }); + }), + + on:t.vi.fn((event: string, handler: (...args: any[]) => void) => { + if (!eventListeners.has(event)) { + eventListeners.set(event, []); + } + eventListeners.get(event)!.push({ handler, once: false }); + }), + + off:t.vi.fn((event: string, handler?: (...args: any[]) => void) => { + if (!eventListeners.has(event)) return; + + if (handler) { + // Remove specific handler + const listeners = eventListeners.get(event)!; + const index = listeners.findIndex((listener) => listener.handler === handler); + if (index !== -1) { + listeners.splice(index, 1); + } + } else { + // Remove all handlers for this event + eventListeners.delete(event); + } + }), + + // Method to emit events (for testing purposes) + emit:t.vi.fn((event: string, ...args: any[]) => { + if (!eventListeners.has(event)) return; + + const listeners = eventListeners.get(event)!; + // Create a copy to iterate over, as 'once' handlers will modify the original array + const listenersToCall = [...listeners]; + + listenersToCall.forEach(({ handler, once }) => { + try { + handler(...args); + } catch (error) { + console.error(`Error in event handler for '${event}':`, error); + } + + // Remove 'once' handlers after calling them + if (once) { + const index = listeners.findIndex((l) => l.handler === handler); + if (index !== -1) { + listeners.splice(index, 1); + } + } + }); + }), + + // Helper methods for testing + getEventListeners:t.vi.fn((event?: string) => { + if (event) { + return eventListeners.get(event) || []; + } + return Object.fromEntries(eventListeners); + }), + + clearEventListeners:t.vi.fn((event?: string) => { + if (event) { + eventListeners.delete(event); + } else { + eventListeners.clear(); + } + }), + } as any; + + + createDappClientMock.mockImplementation(() => mockDappClient); + defaultTransportMock.mockReturnValue(mockDefaultTransport); + + t.vi.spyOn(MultichainSDK.prototype as any, 'createDappClient').mockImplementation(() => { + return mockDappClient; + }); + + // Setup platform-specific mocks + setupMocks?.(nativeStorageStub); + return { + initSpy, + setupAnalyticsSpy, + emitSpy, + showInstallModalSpy, + nativeStorageStub, + mockDappClient, + mockDefaultTransport, + mockLogger, + mockWalletGetSession, + mockWalletCreateSession, + mockWalletRevokeSession, + mockWalletInvokeMethod, + mockSessionRequest, + }; + } catch (error) { + console.error('Error in beforeEach', error); + throw error; + } + } + + async function afterEach(mocks: MockedData) { + // Clear storage + mocks.nativeStorageStub.data.clear(); + + // Restore spies + mocks.setupAnalyticsSpy?.mockRestore(); + mocks.initSpy?.mockRestore(); + mocks.emitSpy?.mockRestore(); + + mocks.mockWalletGetSession.mockRestore(); + mocks.mockWalletCreateSession.mockRestore(); + mocks.mockSessionRequest.mockRestore(); + mocks.mockWalletRevokeSession.mockRestore(); + mocks.showInstallModalSpy.mockRestore(); + + // Clear all mocks + vi.clearAllMocks(); + + // Run custom cleanup + cleanupMocks?.(); + } + + tests({ + platform, + createSDK, + options, + beforeEach, + afterEach, + storage: nativeStorageStub, + }); +}; diff --git a/packages/sdk-multichain/tests/mocks/analytics.ts b/packages/sdk-multichain/tests/mocks/analytics.ts new file mode 100644 index 000000000..318f1f43f --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/analytics.ts @@ -0,0 +1,14 @@ +/** + * This file mocks Analytics package in the SDK + * Allowing us to know if specific events triggered or not + */ +import * as t from 'vitest'; + +t.vi.mock('@metamask/sdk-analytics', () => ({ + analytics: { + setGlobalProperty:t.vi.fn(), + enable:t.vi.fn(), + track:t.vi.fn(), + }, +})); + diff --git a/packages/sdk-multichain/tests/mocks/apiClient.ts b/packages/sdk-multichain/tests/mocks/apiClient.ts new file mode 100644 index 000000000..f7d1671ac --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/apiClient.ts @@ -0,0 +1,15 @@ +/** + * This file mocks API Client package in the SDK + * We just wrap the getDefaultTransport to have the ability to mock it after in fixtures file + */ +import * as t from 'vitest'; + +t.vi.mock('@metamask/multichain-api-client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDefaultTransport: t.vi.fn(() => { + return actual.getDefaultTransport() + }) + }; +}); diff --git a/packages/sdk-multichain/tests/mocks/dappClient.ts b/packages/sdk-multichain/tests/mocks/dappClient.ts new file mode 100644 index 000000000..d642f08be --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/dappClient.ts @@ -0,0 +1,6 @@ +/** + * This file mocks Dapp Client package in the SDK + * Allowing us to mock DappClient completelly in our tests (see fixtures.test.ts) + */ +import * as t from 'vitest'; +t.vi.mock('@metamask/mobile-wallet-protocol-dapp-client'); diff --git a/packages/sdk-multichain/tests/mocks/index.ts b/packages/sdk-multichain/tests/mocks/index.ts new file mode 100644 index 000000000..eeef8a7a7 --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/index.ts @@ -0,0 +1,7 @@ +import './logger'; +import './analytics'; +import './apiClient'; +import './dappClient'; +import './uiPackage'; +import './ws'; +import './mwp'; diff --git a/packages/sdk-multichain/tests/mocks/logger.ts b/packages/sdk-multichain/tests/mocks/logger.ts new file mode 100644 index 000000000..e3c4f733d --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/logger.ts @@ -0,0 +1,13 @@ +import * as t from 'vitest'; + +t.vi.mock('../../src/domain/logger', () => { + const __mockLogger =t.vi.fn(); + return { + createLogger:t.vi.fn(() => __mockLogger), + enableDebug:t.vi.fn(() => {}), + isEnabled:t.vi.fn(() => true), + __mockLogger, + }; +}); + + diff --git a/packages/sdk-multichain/tests/mocks/mwp.ts b/packages/sdk-multichain/tests/mocks/mwp.ts new file mode 100644 index 000000000..52e09b80d --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/mwp.ts @@ -0,0 +1,34 @@ +import * as t from 'vitest'; + +type PendingRequests = { + resolve: (value: any) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +}; + +t.vi.mock('../../src/multichain/transports/mwp', async (importOriginal) => { + const { + MWPTransport + } = await importOriginal(); + + // Create a mock Map to store pending requests + const mockPendingRequestsMap = new Map(); + + // Create a mock class that extends the original MWPTransport + class MockMWPTransport extends MWPTransport { + private __mockPendingRequests = mockPendingRequestsMap; + + get pendingRequests() { + return this.__mockPendingRequests; + } + + set pendingRequests(pendingRequests: Map) { + this.__mockPendingRequests = pendingRequests; + } + } + + return { + MWPTransport: MockMWPTransport, + __mockPendingRequestsMap: mockPendingRequestsMap + }; +}); diff --git a/packages/sdk-multichain/tests/mocks/uiPackage.ts b/packages/sdk-multichain/tests/mocks/uiPackage.ts new file mode 100644 index 000000000..ad08af1b0 --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/uiPackage.ts @@ -0,0 +1,6 @@ + +import * as t from 'vitest'; + +t.vi.mock('@metamask/sdk-multichain-ui/dist/loader/index.cjs.js', () => ({ + defineCustomElements:t.vi.fn(), +})); diff --git a/packages/sdk-multichain/tests/mocks/ws.ts b/packages/sdk-multichain/tests/mocks/ws.ts new file mode 100644 index 000000000..01414aa7e --- /dev/null +++ b/packages/sdk-multichain/tests/mocks/ws.ts @@ -0,0 +1,38 @@ +import * as t from 'vitest'; + +// Mock WebSocket at the top level +const createMockWebSocket = () => { + const mockWS = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, + readyState: 1, + url: '', + protocol: '', + bufferedAmount: 0, + extensions: '', + binaryType: 'blob' as BinaryType, + onopen: null as ((event: Event) => void) | null, + onmessage: null as ((event: MessageEvent) => void) | null, + onerror: null as ((event: Event) => void) | null, + onclose: null as ((event: CloseEvent) => void) | null, + send: t.vi.fn(), + close: t.vi.fn(), + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + dispatchEvent: t.vi.fn(), + }; + return mockWS; +}; + +t.vi.mock('ws', () => { + return { + default: t.vi.fn().mockImplementation(() => createMockWebSocket()), + WebSocket: t.vi.fn().mockImplementation(() => createMockWebSocket()), + } +}); + +// Mock native WebSocket for browser environments +const mockWebSocketConstructor = t.vi.fn().mockImplementation(() => createMockWebSocket()); +t.vi.stubGlobal('WebSocket', mockWebSocketConstructor); diff --git a/packages/sdk-multichain/tests/types.ts b/packages/sdk-multichain/tests/types.ts new file mode 100644 index 000000000..39a3242a7 --- /dev/null +++ b/packages/sdk-multichain/tests/types.ts @@ -0,0 +1,61 @@ +import * as t from 'vitest'; + +import { SessionRequest, SessionStore } from '@metamask/mobile-wallet-protocol-core'; +import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; +import { Transport, SessionData } from '@metamask/multichain-api-client'; +import { MultichainOptions, MultichainCore } from '../src/domain'; +import { MultichainSDK } from '../src/multichain'; + +type GetItem = (key: string) => string | null; +type SetItem = (key: string, value: string) => void; +type RemoveItem = (key: string) => void; +type Clear = () => void; + +export type NativeStorageStub = { + data: Map; + getItem: t.Mock; + setItem: t.Mock; + removeItem: t.Mock; + clear: t.Mock; +}; + +export type MockedData = { + initSpy: t.MockInstance; + setupAnalyticsSpy: t.MockInstance; + emitSpy: t.MockInstance; + showInstallModalSpy: t.MockInstance; + nativeStorageStub: NativeStorageStub; + + mockDappClient: t.Mocked; + mockDefaultTransport: t.Mocked; + mockLogger: t.MockInstance; + + // Mocking RPC method responses for all transports + mockWalletGetSession: t.MockInstance<(request: any) => Promise>; + mockWalletCreateSession: t.MockInstance<(request: any) => Promise>; + mockWalletRevokeSession: t.MockInstance<(request: any) => Promise>; + mockWalletInvokeMethod: t.MockInstance<(request: any) => Promise>; + + // Mocking MWP session request + mockSessionRequest: t.MockInstance<() => Promise>; +}; + +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' | 'web-mobile'; + options: T; + createSDK: (options: T) => Promise; + setupMocks?: (options: NativeStorageStub) => void; + cleanupMocks?: () => void; + tests: (options: TestSuiteOptions) => void; +}; + +export type CreateTestFN = (options: Options) => void; diff --git a/packages/sdk-multichain/tsconfig.json b/packages/sdk-multichain/tsconfig.json index 515b2c0c8..c62083ea4 100644 --- a/packages/sdk-multichain/tsconfig.json +++ b/packages/sdk-multichain/tsconfig.json @@ -21,6 +21,6 @@ } // "typeRoots": ["packages/sdk/src/types/global.d.ts", "src/types/global.d.ts"] }, - "include": ["./src", "./tools"], + "include": ["./src", "./tests"], "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/playground/multichain-react/craco.config.js b/playground/multichain-react/craco.config.js index cf0e7cd10..ad1e543ee 100644 --- a/playground/multichain-react/craco.config.js +++ b/playground/multichain-react/craco.config.js @@ -3,17 +3,28 @@ const webpack = require('webpack'); module.exports = { webpack: { configure: (webpackConfig) => { + // Control export conditions (for packages with conditional exports) + webpackConfig.resolve.conditionNames = [ + 'browser', 'import','require', 'default' + ]; + + // === NODE.JS POLYFILLS === webpackConfig.resolve.fallback = { ...webpackConfig.resolve.fallback, buffer: require.resolve('buffer'), process: require.resolve('process/browser.js'), }; + + // === PROVIDE PLUGINS === webpackConfig.plugins.push( new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], process: 'process/browser.js', }), ); + + + return webpackConfig; }, }, diff --git a/playground/multichain-react/package.json b/playground/multichain-react/package.json index bbcd42432..1773e3610 100644 --- a/playground/multichain-react/package.json +++ b/playground/multichain-react/package.json @@ -53,7 +53,6 @@ "@lavamoat/preinstall-always-fail": "^2.0.0", "@metamask/api-specs": "^0.14.0", "@metamask/auto-changelog": "^3.4.3", - "@metamask/utils": "^11.0.0", "@open-rpc/meta-schema": "^1.14.9", "@open-rpc/schema-utils-js": "^2.0.5", "@solana/web3.js": "^1.98.0", @@ -95,7 +94,11 @@ }, "dependencies": { "@metamask/multichain-sdk": "workspace:^", + "@metamask/utils": "^11.8.1", "react": "^18.3.1", "react-dom": "^18.3.1" + }, + "resolutions": { + "@metamask/utils": "^11.8.1" } } diff --git a/playground/multichain-react/src/App.tsx b/playground/multichain-react/src/App.tsx index 56d319281..1ce874bd4 100644 --- a/playground/multichain-react/src/App.tsx +++ b/playground/multichain-react/src/App.tsx @@ -12,7 +12,7 @@ global.Buffer = Buffer; function App() { const [customScopes, setCustomScopes] = useState(['eip155:1']); const [caipAccountIds, setCaipAccountIds] = useState([]); - const { state, session, connect: sdkConnect, disconnect: sdkDisconnect } = useSDK(); + const { error, state, session, connect: sdkConnect, disconnect: sdkDisconnect } = useSDK(); const handleCheckboxChange = useCallback( (value: string, isChecked: boolean) => { @@ -27,13 +27,13 @@ function App() { useEffect(() => { if (session) { - const scopes = Object.keys(session.sessionScopes); + const scopes = Object.keys(session?.sessionScopes ?? {}); setCustomScopes(scopes); // Accumulate all accounts from all scopes const allAccounts: CaipAccountId[] = []; for (const scope of scopes) { - const { accounts } = session.sessionScopes[scope as keyof typeof session.sessionScopes] ?? {}; + const { accounts } = session.sessionScopes?.[scope as keyof typeof session.sessionScopes] ?? {}; if (accounts && accounts.length > 0) { allAccounts.push(...accounts); } @@ -44,7 +44,7 @@ function App() { const scopesHaveChanged = useCallback(() => { if (!session) return false; - const sessionScopes = Object.keys(session.sessionScopes); + const sessionScopes = Object.keys(session?.sessionScopes ?? {}); const currentScopes = customScopes.filter((scope) => scope.length); if (sessionScopes.length !== currentScopes.length) return true; return !sessionScopes.every((scope) => currentScopes.includes(scope)) || !currentScopes.every((scope) => sessionScopes.includes(scope)); @@ -69,7 +69,6 @@ function App() { const isDisconnected = state === 'disconnected' || state === 'pending' || state === 'loaded'; const isConnected = state === 'connected'; const isConnecting = state === 'connecting'; - return (
@@ -101,6 +100,12 @@ function App() { )} + {error && ( +
+

Error

+

{error.message.toString()}

+
+ )}
{Object.keys(session?.sessionScopes ?? {}).length > 0 && (
diff --git a/playground/multichain-react/src/sdk/SDKProvider.tsx b/playground/multichain-react/src/sdk/SDKProvider.tsx index 7b5eb6623..a7a4461fc 100644 --- a/playground/multichain-react/src/sdk/SDKProvider.tsx +++ b/playground/multichain-react/src/sdk/SDKProvider.tsx @@ -1,16 +1,16 @@ /* eslint-disable */ -import { createMetamaskSDK, type SDKState, type InvokeMethodOptions, type Scope, type SessionData } from '@metamask/multichain-sdk'; +import { createMetamaskSDK, type SDKState, type InvokeMethodOptions, type Scope, type SessionData, type MultichainCore } from '@metamask/multichain-sdk'; import type { CaipAccountId } from '@metamask/utils'; import type React from 'react'; -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { METAMASK_PROD_CHROME_ID } from '../constants'; const SDKContext = createContext< | { session: SessionData | undefined; state: SDKState; - error: string | null; + error: Error | null; connect: (scopes: Scope[], caipAccountIds: CaipAccountId[]) => Promise; disconnect: () => Promise; invokeMethod: (options: InvokeMethodOptions) => Promise; @@ -18,63 +18,70 @@ const SDKContext = createContext< | undefined >(undefined); +let sdk: MultichainCore | null = null; + export const SDKProvider = ({ children }: { children: React.ReactNode }) => { const [state, setState] = useState('pending'); const [session, setSession] = useState(undefined); - const [error, setError] = useState(null); + const [error, setError] = useState(null); - const sdk = useMemo(() => { - setState('pending'); - const response = createMetamaskSDK({ - dapp: { - name: 'playground', - url: 'https://playground.metamask.io', - }, - analytics: { - enabled: false, - }, - transport: { - extensionId: METAMASK_PROD_CHROME_ID, - onResumeSession: (resumedSession: SessionData) => { - console.log('session resumed', resumedSession); - setSession(resumedSession); + const sdkPromise = useMemo(async () => { + if (!sdk) { + sdk = await createMetamaskSDK({ + dapp: { + name: 'playground', + url: 'https://playground.metamask.io', + }, + analytics: { + enabled: false, + }, + transport: { + extensionId: METAMASK_PROD_CHROME_ID, + onNotification: (notification: unknown) => { + const payload = notification as Record; + if (payload.method === 'wallet_sessionChanged' || payload.method === 'wallet_createSession' || payload.method === 'wallet_getSession') { + setSession(payload.params as SessionData); + } else if (payload.method === 'stateChanged') { + setState(payload.params as SDKState); + } + }, }, - }, - }); - response.then((sdk) => { - setState(sdk.state); - sdk.on('wallet_sessionChanged', (newSession) => { - setSession(newSession); }); - }); - return response; + } + return sdk; }, []); const disconnect = useCallback(async () => { - const sdkInstance = await sdk; - setState(sdkInstance.state); - return sdkInstance.disconnect(); - }, [sdk]); + try { + const sdkInstance = await sdkPromise; + return sdkInstance.disconnect(); + } catch (error) { + setError(error); + } + }, [sdkPromise]); const connect = useCallback( async (scopes: Scope[], caipAccountIds: CaipAccountId[]) => { - const sdkInstance = await sdk; - setState(sdkInstance.state); - await sdkInstance.connect(scopes, caipAccountIds); - setState(sdkInstance.state); - const newSession = await sdkInstance.getCurrentSession(); - setSession(newSession); + try { + const sdkInstance = await sdkPromise; + await sdkInstance.connect(scopes, caipAccountIds); + } catch (error) { + setError(error); + } }, - [sdk], + [sdkPromise], ); const invokeMethod = useCallback( async (options: InvokeMethodOptions) => { - const sdkInstance = await sdk; - setState(sdkInstance.state); - return sdkInstance.invokeMethod(options); + try { + const sdkInstance = await sdkPromise; + return sdkInstance.invokeMethod(options); + } catch (error) { + setError(error); + } }, - [sdk], + [sdkPromise], ); return ( diff --git a/yarn.lock b/yarn.lock index e4f893883..7602f0966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11525,25 +11525,25 @@ __metadata: languageName: node linkType: hard -"@metamask/mobile-wallet-protocol-core@npm:^0.1.0, @metamask/mobile-wallet-protocol-core@npm:^0.1.1": - version: 0.1.1 - resolution: "@metamask/mobile-wallet-protocol-core@npm:0.1.1" +"@metamask/mobile-wallet-protocol-core@npm:^0.2.0": + version: 0.2.0 + resolution: "@metamask/mobile-wallet-protocol-core@npm:0.2.0" dependencies: centrifuge: ^5.3.5 eventemitter3: ^5.0.1 uuid: ^11.1.0 - checksum: c19e17999d25b0872152961489560dc252ae7a7bbce058dadb86fccfbd8652db19fe98cc5c292f67508e92c9c429eb63489ff166deea5a0d8d370bee291a5d5a + checksum: 963a4c8aabae2318bab973055125d1474a29451dc013e87744e3a04608982934b9916c1d28699207c11604386348b6c2ed007125ed9a0ee2c98d0604b9358c4f languageName: node linkType: hard -"@metamask/mobile-wallet-protocol-dapp-client@npm:^0.1.0": - version: 0.1.1 - resolution: "@metamask/mobile-wallet-protocol-dapp-client@npm:0.1.1" +"@metamask/mobile-wallet-protocol-dapp-client@npm:^0.2.1": + version: 0.2.1 + resolution: "@metamask/mobile-wallet-protocol-dapp-client@npm:0.2.1" dependencies: - "@metamask/mobile-wallet-protocol-core": ^0.1.1 + "@metamask/mobile-wallet-protocol-core": ^0.2.0 "@metamask/utils": ^9.1.0 uuid: ^11.1.0 - checksum: 0e5a4476adeed037b6b6643ab4be617439437db555f6b3d04aeaa378e4b7ea45ed791eed0cde05df8306fea426a6eeae16e6bcd3e0970165408f46b6eb43d5c4 + checksum: 947b6758507093520d54ea618bdc1e01e42bcd995a2f4b16b8504b139c2aec41d192680d7880ecdb2aa9d676f6a3205a6c708e7bb1f2b44fb57ec8715d34385d languageName: node linkType: hard @@ -11573,8 +11573,8 @@ __metadata: dependencies: "@biomejs/biome": 2.0.0 "@metamask/auto-changelog": ^3.4.3 - "@metamask/mobile-wallet-protocol-core": ^0.1.0 - "@metamask/mobile-wallet-protocol-dapp-client": ^0.1.0 + "@metamask/mobile-wallet-protocol-core": ^0.2.0 + "@metamask/mobile-wallet-protocol-dapp-client": ^0.2.1 "@metamask/multichain-api-client": ^0.8.0 "@metamask/onboarding": ^1.0.1 "@metamask/sdk-analytics": "workspace:^" @@ -11582,6 +11582,7 @@ __metadata: "@metamask/utils": ^11.4.0 "@paulmillr/qr": ^0.2.1 "@react-native-async-storage/async-storage": ^1.23.1 + "@types/jsdom": ^21.1.7 "@types/ws": ^8.18.1 "@vitest/coverage-v8": ^3.2.4 bowser: ^2.11.0 @@ -11952,7 +11953,7 @@ __metadata: "@metamask/api-specs": ^0.14.0 "@metamask/auto-changelog": ^3.4.3 "@metamask/multichain-sdk": "workspace:^" - "@metamask/utils": ^11.0.0 + "@metamask/utils": ^11.8.1 "@open-rpc/meta-schema": ^1.14.9 "@open-rpc/schema-utils-js": ^2.0.5 "@solana/web3.js": ^1.98.0 @@ -12554,40 +12555,40 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.0.0": - version: 11.8.0 - resolution: "@metamask/utils@npm:11.8.0" +"@metamask/utils@npm:^11.4.0": + version: 11.4.2 + resolution: "@metamask/utils@npm:11.4.2" dependencies: "@ethereumjs/tx": ^4.2.0 "@metamask/superstruct": ^3.1.0 "@noble/hashes": ^1.3.1 "@scure/base": ^1.1.3 "@types/debug": ^4.1.7 - "@types/lodash": ^4.17.20 debug: ^4.3.4 - lodash: ^4.17.21 + lodash.memoize: ^4.1.2 pony-cause: ^2.1.10 semver: ^7.5.4 uuid: ^9.0.1 - checksum: 61f0eb5f9066ea7f59637389910698d78e48fc810669d156dc1cfea879699cbc12644933ee04929dfc14bc100440a241003a1b68de9e0c41f292c4f290af1ae6 + checksum: 11061a93f49684563a14caaaab2d8dbb969c907dbc24358cf188dd10ec00ac91e5d04369ef605e9d78e75f8ad53d9a0fbdb65f2325b12ef6c8db85bb46160dff languageName: node linkType: hard -"@metamask/utils@npm:^11.4.0": - version: 11.4.2 - resolution: "@metamask/utils@npm:11.4.2" +"@metamask/utils@npm:^11.8.1": + version: 11.8.1 + resolution: "@metamask/utils@npm:11.8.1" dependencies: "@ethereumjs/tx": ^4.2.0 "@metamask/superstruct": ^3.1.0 "@noble/hashes": ^1.3.1 "@scure/base": ^1.1.3 "@types/debug": ^4.1.7 + "@types/lodash": ^4.17.20 debug: ^4.3.4 - lodash.memoize: ^4.1.2 + lodash: ^4.17.21 pony-cause: ^2.1.10 semver: ^7.5.4 uuid: ^9.0.1 - checksum: 11061a93f49684563a14caaaab2d8dbb969c907dbc24358cf188dd10ec00ac91e5d04369ef605e9d78e75f8ad53d9a0fbdb65f2325b12ef6c8db85bb46160dff + checksum: 4a2a355c7875eea28ba5750ba771d3b2aa966e77acbac9b7966e7e154cf81fd2c4c9bc08e5a0382a1b743ad6f441eb259ca4720585ef00c0b23f1582207f43c1 languageName: node linkType: hard @@ -19979,6 +19980,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^21.1.7": + version: 21.1.7 + resolution: "@types/jsdom@npm:21.1.7" + dependencies: + "@types/node": "*" + "@types/tough-cookie": "*" + parse5: ^7.0.0 + checksum: b7465d5a471ed4e68a54e2639c534d364134674598687be69645736731215e7407fe37a4af66dc616ef03be9c5515cb355df2eda5c8080146c05bd569ea8810d + languageName: node + linkType: hard + "@types/json-schema@npm:*, @types/json-schema@npm:^7.0.4, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.7, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": version: 7.0.12 resolution: "@types/json-schema@npm:7.0.12"