diff --git a/packages/connect-evm/CHANGELOG.md b/packages/connect-evm/CHANGELOG.md index 0c0c0494..17ad6d58 100644 --- a/packages/connect-evm/CHANGELOG.md +++ b/packages/connect-evm/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `debug` option param used by `createEVMClient()` now enables console debug logs of the underlying `MultichainClient` instance ([#149](https://github.com/MetaMask/connect-monorepo/pull/149)) - update `connect()` and `createEVMClient()` typings to be more accurate ([#153](https://github.com/MetaMask/connect-monorepo/pull/153)) - update `switchChain()` to return `Promise` ([#153](https://github.com/MetaMask/connect-monorepo/pull/153)) +- Make `ConnectEvm` rely on `wallet_sessionChanged` events from `ConnectMultichain` rather than explicit connect/disconnect events ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) +- Chain add/switch deeplink now calls `openSimpleDeeplinkIfNeeded()` instead of `openDeeplinkIfNeeded()` to align with `@metamask/connect-multichain` changes ([#176](https://github.com/MetaMask/connect-monorepo/pull/176)) ### Fixed diff --git a/packages/connect-evm/package.json b/packages/connect-evm/package.json index 38bb4208..eddf1df7 100644 --- a/packages/connect-evm/package.json +++ b/packages/connect-evm/package.json @@ -47,8 +47,9 @@ "dev": "tsup --watch ", "publish:preview": "yarn npm publish --tag preview", "since-latest-release": "../../scripts/since-latest-release.sh", - "test": "vitest run", - "test:ci": "vitest run --coverage --coverage.reporter=text --silent", + "pretest": "yarn workspace @metamask/analytics run build && yarn workspace @metamask/multichain-ui run build && yarn workspace @metamask/connect-multichain run build", + "test": "yarn pretest && vitest run", + "test:ci": "yarn pretest && vitest run --coverage --coverage.reporter=text --silent", "test:unit": "vitest run", "test:verbose": "vitest run --reporter=verbose", "test:watch": "vitest watch" diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index 34a74163..7f2ecafa 100644 --- a/packages/connect-evm/src/connect.test.ts +++ b/packages/connect-evm/src/connect.test.ts @@ -1,8 +1,348 @@ /* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ -import { describe, it, expect } from 'vitest'; +import type { SessionData, MultichainCore } from '@metamask/connect-multichain'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -describe('smoke', () => { - it('works', () => { - expect(true).toBe(true); +import type { ConnectEvmStatus } from './connect'; +import { MetamaskConnectEVM } from './connect'; + +type MockCore = MultichainCore & { + emit: (event: string, ...args: unknown[]) => void; + _status: ConnectEvmStatus; + storage: MultichainCore['storage'] & { + adapter: { + get: Mock<(key: string) => Promise>; + set: Mock<(key: string, value: string) => Promise>; + }; + }; + transport: MultichainCore['transport'] & { + sendEip1193Message: Mock; + }; + disconnect: Mock<(scopes?: unknown[]) => Promise>; + connect: Mock< + ( + scopes: unknown[], + caipAccountIds: unknown[], + sessionProperties?: unknown, + forceRequest?: boolean, + ) => Promise + >; +}; + +/** + * Creates a mock MultichainCore for testing. + * + * @returns A mock core instance implementing MockCore. + */ +function createMockCore(): MockCore { + const handlers: Record void)[]> = {}; + const _status: ConnectEvmStatus = 'disconnected'; + + const sendEip1193Message = vi.fn().mockResolvedValue({ + result: [] as string[], + id: 1, + jsonrpc: '2.0' as const, + }); + const onNotification = vi.fn().mockReturnValue(() => { + // noop + }); + + const storageGet = vi.fn().mockResolvedValue(null); + const storageSet = vi.fn().mockResolvedValue(undefined); + + const mockCore = { + // eslint-disable-next-line @typescript-eslint/naming-convention -- mock mirrors real class _status + _status: _status as ConnectEvmStatus, + get status(): ConnectEvmStatus { + return this._status; + }, + set status(value: ConnectEvmStatus) { + this._status = value; + }, + on(event: string, handler: (...args: unknown[]) => void): void { + if (!handlers[event]) { + handlers[event] = []; + } + handlers[event].push(handler); + }, + emit(event: string, ...args: unknown[]): void { + handlers[event]?.forEach((handler) => handler(...args)); + }, + emitSessionChanged: vi.fn().mockImplementation(async (): Promise => { + mockCore.emit('wallet_sessionChanged', { sessionScopes: {} }); + }), + disconnect: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(undefined), + transport: { + sendEip1193Message, + onNotification, + }, + storage: { + adapter: { + get: storageGet, + set: storageSet, + }, + }, + }; + + mockCore._status = _status; + return mockCore as unknown as MockCore; +} + +describe('MetamaskConnectEVM', () => { + describe('#onSessionChanged', () => { + describe('disconnects', () => { + let mockCore: MockCore; + let client: Awaited>; + + beforeEach(async () => { + mockCore = createMockCore(); + mockCore.storage.adapter.get.mockResolvedValue(JSON.stringify('0x1')); + client = await MetamaskConnectEVM.create({ core: mockCore }); + const session: SessionData = { + sessionScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + }, + }; + mockCore.emit('wallet_sessionChanged', session); + await new Promise((resolve) => { + client.getProvider().once('connect', () => resolve()); + }); + }); + + it('disconnects when session has no permitted EIP-155 chain IDs if the MultichainClient is connected', async () => { + const disconnectPromise = new Promise((resolve) => { + client.getProvider().once('disconnect', resolve); + }); + + const newSession: SessionData = { + sessionScopes: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + methods: [], + notifications: [], + accounts: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:1234567890'], + }, + }, + }; + + mockCore.emit('wallet_sessionChanged', newSession); + await disconnectPromise; + expect(client.accounts).toEqual([]); + }); + + it('disconnects when wallet_sessionChanged is emitted with undefined session after being connected', async () => { + const disconnectPromise = new Promise((resolve) => { + client.getProvider().once('disconnect', resolve); + }); + mockCore.emit('wallet_sessionChanged', undefined); + await disconnectPromise; + expect(client.accounts).toEqual([]); + }); + + it('disconnects when wallet_sessionChanged is emitted with empty sessionScopes after being connected', async () => { + const disconnectPromise = new Promise((resolve) => { + client.getProvider().once('disconnect', resolve); + }); + mockCore.emit('wallet_sessionChanged', { sessionScopes: {} }); + await disconnectPromise; + expect(client.accounts).toEqual([]); + }); + }); + + describe('connects', () => { + it('connects using the accounts from the CAIP-25 permissions when the MultichainClient is disconnected', async () => { + const mockCore = createMockCore(); + mockCore.storage.adapter.get.mockResolvedValue(JSON.stringify('0x1')); + const client = await MetamaskConnectEVM.create({ core: mockCore }); + + const connectPromise = new Promise<{ + chainId: string; + accounts: string[]; + }>((resolve) => { + client.getProvider().once('connect', resolve); + }); + + const session: SessionData = { + sessionScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + }, + }; + mockCore.emit('wallet_sessionChanged', session); + + const connectData = await connectPromise; + expect(connectData.chainId).toBe('0x1'); + expect(connectData.accounts).toContain( + '0x1234567890123456789012345678901234567890', + ); + }); + + it('connects using accounts from a eth_accounts response when the MultichainClient is connected', async () => { + const mockCore = createMockCore(); + mockCore._status = 'connected'; + mockCore.storage.adapter.get.mockResolvedValue(JSON.stringify('0x1')); + mockCore.transport.sendEip1193Message.mockResolvedValue({ + result: ['0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], + id: 1, + jsonrpc: '2.0', + }); + + const client = await MetamaskConnectEVM.create({ core: mockCore }); + + const connectPromise = new Promise<{ + chainId: string; + accounts: string[]; + }>((resolve) => { + client.getProvider().once('connect', resolve); + }); + + const session: SessionData = { + sessionScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + }, + }; + mockCore.emit('wallet_sessionChanged', session); + + const connectData = await connectPromise; + expect(connectData.chainId).toBe('0x1'); + expect(connectData.accounts).toContain( + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ); + expect(mockCore.transport.sendEip1193Message).toHaveBeenCalledWith({ + method: 'eth_accounts', + params: [], + }); + }); + + it('connects using the cached eth_chainId when valid and also in the CAIP-25 permission scopes', async () => { + const mockCore = createMockCore(); + mockCore.storage.adapter.get.mockResolvedValue(JSON.stringify('0x89')); // Polygon + const client = await MetamaskConnectEVM.create({ core: mockCore }); + + const connectPromise = new Promise<{ chainId: string }>((resolve) => { + client.getProvider().once('connect', (data) => resolve(data)); + }); + + const session: SessionData = { + sessionScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'eip155:137': { + methods: [], + notifications: [], + accounts: [ + 'eip155:137:0x1234567890123456789012345678901234567890', + ], + }, + }, + }; + mockCore.emit('wallet_sessionChanged', session); + + const connectData = await connectPromise; + expect(connectData.chainId).toBe('0x89'); + }); + + it('connects using the first permitted chain id from the CAIP-25 permission when there is no cached eth_chainId', async () => { + const mockCore = createMockCore(); + mockCore.storage.adapter.get.mockResolvedValue(null); + const client = await MetamaskConnectEVM.create({ core: mockCore }); + + const connectPromise = new Promise<{ chainId: string }>((resolve) => { + client.getProvider().once('connect', (data) => resolve(data)); + }); + + const session: SessionData = { + sessionScopes: { + 'eip155:11155111': { + methods: [], + notifications: [], + accounts: [ + 'eip155:11155111:0x1234567890123456789012345678901234567890', + ], + }, + }, + }; + mockCore.emit('wallet_sessionChanged', session); + + const connectData = await connectPromise; + expect(connectData.chainId).toBe('0xaa36a7'); // sepolia + }); + }); + }); + + describe('connect', () => { + it('resolves with the value emitted by the provider connect event (triggered by wallet_sessionChanged)', async () => { + const mockCore = createMockCore(); + mockCore.storage.adapter.get.mockResolvedValue(JSON.stringify('0x1')); + mockCore.connect.mockImplementation(async (): Promise => { + const session: SessionData = { + sessionScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + }, + }; + mockCore.emit('wallet_sessionChanged', session); + }); + const client = await MetamaskConnectEVM.create({ core: mockCore }); + + const result = await client.connect({ chainIds: ['0x1'] }); + + expect(result).toEqual({ + chainId: '0x1', + accounts: ['0x1234567890123456789012345678901234567890'], + }); + }); + }); + + describe('disconnect', () => { + it('calls core.disconnect with all eip155 scopes from the current session', async () => { + const mockCore = createMockCore(); + mockCore.storage.adapter.get.mockResolvedValue(JSON.stringify('0x1')); + const client = await MetamaskConnectEVM.create({ core: mockCore }); + + const session: SessionData = { + sessionScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'eip155:137': { + methods: [], + notifications: [], + accounts: ['eip155:137:0x1234567890123456789012345678901234567890'], + }, + }, + }; + mockCore.emit('wallet_sessionChanged', session); + await new Promise((resolve) => { + client.getProvider().once('connect', () => resolve()); + }); + + await client.disconnect(); + + expect(mockCore.disconnect).toHaveBeenCalledTimes(1); + const [scopes] = mockCore.disconnect.mock.calls[0]; + expect(scopes).toEqual( + expect.arrayContaining(['eip155:1', 'eip155:137']), + ); + expect(scopes).toHaveLength(2); + }); }); }); diff --git a/packages/connect-evm/src/connect.ts b/packages/connect-evm/src/connect.ts index 5af19d2a..0dee314d 100644 --- a/packages/connect-evm/src/connect.ts +++ b/packages/connect-evm/src/connect.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-syntax -- Private class properties use established patterns */ import { analytics } from '@metamask/analytics'; -import type { Caip25CaveatValue } from '@metamask/chain-agnostic-permission'; +import { parseScopeString } from '@metamask/chain-agnostic-permission'; import type { ConnectionStatus, MultichainCore, @@ -29,7 +29,7 @@ import type { ProviderRequest, ProviderRequestInterceptor, } from './types'; -import { getPermittedEthChainIds } from './utils/caip'; +import { getEthAccounts, getPermittedEthChainIds } from './utils/caip'; import { isAccountsRequest, isAddChainRequest, @@ -52,6 +52,8 @@ type ConnectOptions = { chainIds?: Hex[]; }; +export type ConnectEvmStatus = 'disconnected' | 'connected' | 'connecting'; + /** * The MetamaskConnectEVM class provides an EIP-1193 compatible interface for connecting * to MetaMask and interacting with Ethereum Virtual Machine (EVM) networks. @@ -102,6 +104,9 @@ export class MetamaskConnectEVM { /** The clean-up function for the notification handler */ #removeNotificationHandler?: () => void; + /** The current connection status */ + #status: ConnectEvmStatus = 'disconnected'; + /** * Creates a new MetamaskConnectEVM instance. * Use the static `create()` method instead to ensure proper async initialization. @@ -126,14 +131,9 @@ export class MetamaskConnectEVM { * * @param session - The session data */ - this.#sessionChangedHandler = (session): void => { - logger('event: wallet_sessionChanged', session); - this.#sessionScopes = session?.sessionScopes ?? {}; - }; - this.#core.on( - 'wallet_sessionChanged', - this.#sessionChangedHandler.bind(this), - ); + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.#sessionChangedHandler = this.#onSessionChanged.bind(this); + this.#core.on('wallet_sessionChanged', this.#sessionChangedHandler); /** * Handles the display_uri event. @@ -160,7 +160,7 @@ export class MetamaskConnectEVM { options: MetamaskConnectEVMOptions, ): Promise { const instance = new MetamaskConnectEVM(options); - await instance.#attemptSessionRecovery(); + await instance.#core.emitSessionChanged(); return instance; } @@ -334,64 +334,36 @@ export class MetamaskConnectEVM { ? caipChainIds.map((caipChainId) => `${caipChainId}:${account}`) : []; - await this.#core.connect( - caipChainIds as Scope[], - caipAccountIds as CaipAccountId[], - undefined, - forceRequest, - ); + this.#status = 'connecting'; - const hexPermittedChainIds = getPermittedEthChainIds(this.#sessionScopes); - - const initialAccounts = await this.#core.transport.sendEip1193Message< - { method: 'eth_accounts'; params: [] }, - { result: string[]; id: number; jsonrpc: '2.0' } - >({ method: 'eth_accounts', params: [] }); - - const chainId = await this.#getSelectedChainId(hexPermittedChainIds); - - this.#onConnect({ - chainId, - accounts: initialAccounts.result as Address[], - }); - - // Remove previous notification handler if it exists - this.#removeNotificationHandler?.(); - - this.#removeNotificationHandler = this.#core.transport.onNotification( - (notification) => { - // @ts-expect-error TODO: address this - if (notification?.method === 'metamask_accountsChanged') { - // @ts-expect-error TODO: address this - const accounts = notification?.params; - logger('transport-event: accountsChanged', accounts); - this.#onAccountsChanged(accounts); - } - - // @ts-expect-error TODO: address this - if (notification?.method === 'metamask_chainChanged') { - // @ts-expect-error TODO: address this - const notificationChainId = notification?.params?.chainId; - logger('transport-event: chainChanged', notificationChainId); - // Cache the chainId for persistence across page refreshes - this.#cacheChainId(notificationChainId).catch((error) => { - logger('Error caching chainId in notification handler', error); + try { + // Wait for the wallet_sessionChanged event to fire and set the provider properties + const result = new Promise((resolve) => { + this.#provider.once('connect', ({ chainId, accounts }) => { + logger('fulfilled-request: connect', { + chainId, + accounts, }); - this.#onChainChanged(notificationChainId); - } - }, - ); + resolve({ + accounts, + chainId: chainId as Hex, + }); + }); + }); - logger('fulfilled-request: connect', { - chainId: chainIds[0], - accounts: this.#provider.accounts, - }); + await this.#core.connect( + caipChainIds as Scope[], + caipAccountIds as CaipAccountId[], + undefined, + forceRequest, + ); - // TODO: update required here since accounts and chainId are now promises - return { - accounts: this.#provider.accounts, - chainId, - }; + return result as Promise<{ accounts: Address[]; chainId: Hex }>; + } catch (error) { + this.#status = 'disconnected'; + logger('Error connecting to wallet', error); + throw error; + } } /** @@ -485,7 +457,13 @@ export class MetamaskConnectEVM { async disconnect(): Promise { logger('request: disconnect'); - await this.#core.disconnect(); + const sessionScopes = this.#sessionScopes; + const eip155Scopes = Object.keys(sessionScopes).filter((scope) => { + const { namespace } = parseScopeString(scope as Scope); + return namespace === 'eip155'; + }); + + await this.#core.disconnect(eip155Scopes as Scope[]); this.#onDisconnect(); this.#clearConnectionState(); @@ -494,10 +472,8 @@ export class MetamaskConnectEVM { // for the lifetime of the SDK instance, allowing reconnection to work properly. // Session-scoped listeners (like the notification handler below) are removed. - if (this.#removeNotificationHandler) { - this.#removeNotificationHandler(); - this.#removeNotificationHandler = undefined; - } + this.#removeNotificationHandler?.(); + this.#removeNotificationHandler = undefined; logger('fulfilled-request: disconnect'); } @@ -727,7 +703,7 @@ export class MetamaskConnectEVM { request.method === 'wallet_addEthereumChain' || request.method === 'wallet_switchEthereumChain' ) { - this.#core.openDeeplinkIfNeeded(); + this.#core.openSimpleDeeplinkIfNeeded(); } return result; } @@ -748,6 +724,34 @@ export class MetamaskConnectEVM { } } + async #onSessionChanged(session?: SessionData): Promise { + logger('event: wallet_sessionChanged', session); + this.#sessionScopes = session?.sessionScopes ?? {}; + const hexPermittedChainIds = getPermittedEthChainIds(this.#sessionScopes); + if (hexPermittedChainIds.length === 0) { + this.#onDisconnect(); + } else { + let initialAccounts: Address[] = []; + if (this.#core.status === 'connected') { + const ethAccountsResponse = + await this.#core.transport.sendEip1193Message({ + method: 'eth_accounts', + params: [], + }); + initialAccounts = ethAccountsResponse.result as Address[]; + } else { + initialAccounts = getEthAccounts(this.#sessionScopes); + } + + const chainId = await this.#getSelectedChainId(hexPermittedChainIds); + + this.#onConnect({ + chainId, + accounts: initialAccounts, + }); + } + } + /** * Handles chain change events and updates the provider's selected chain ID. * @@ -769,6 +773,12 @@ export class MetamaskConnectEVM { * @param accounts - The new list of permitted accounts */ #onAccountsChanged(accounts: Address[]): void { + const accountsUnchanged = + accounts.length === this.#provider.accounts.length && + accounts.every((acct, idx) => acct === this.#provider.accounts[idx]); + if (accountsUnchanged) { + return; + } logger('handler: accountsChanged', accounts); this.#provider.accounts = accounts; this.#provider.emit('accountsChanged', accounts); @@ -795,8 +805,40 @@ export class MetamaskConnectEVM { accounts, }; - this.#provider.emit('connect', data); - this.#eventHandlers?.connect?.(data); + if (this.#status !== 'connected') { + this.#status = 'connected'; + this.#provider.emit('connect', data); + this.#eventHandlers?.connect?.(data); + + this.#removeNotificationHandler?.(); + + // TODO: Verify if #core.on('metamask_accountsChanged') and #core.on('metamask_chainChanged') + // would work here instead + this.#removeNotificationHandler = this.#core.transport.onNotification( + (notification) => { + // @ts-expect-error TODO: address this + if (notification?.method === 'metamask_accountsChanged') { + // @ts-expect-error TODO: address this + const notificationAccounts = notification?.params; + logger('transport-event: accountsChanged', notificationAccounts); + // why are we not caching the accounts here? + this.#onAccountsChanged(notificationAccounts); + } + + // @ts-expect-error TODO: address this + if (notification?.method === 'metamask_chainChanged') { + // @ts-expect-error TODO: address this + const notificationChainId = notification?.params?.chainId; + logger('transport-event: chainChanged', notificationChainId); + // Cache the chainId for persistence across page refreshes + this.#cacheChainId(notificationChainId).catch((error) => { + logger('Error caching chainId in notification handler', error); + }); + this.#onChainChanged(notificationChainId); + } + }, + ); + } this.#onChainChanged(chainId); this.#onAccountsChanged(accounts); @@ -807,6 +849,11 @@ export class MetamaskConnectEVM { * Also clears accounts by triggering an accountsChanged event with an empty array. */ #onDisconnect(): void { + if (this.#status === 'disconnected') { + return; + } + this.#status = 'disconnected'; + logger('handler: disconnect'); this.#provider.emit('disconnect'); this.#eventHandlers?.disconnect?.(); @@ -821,66 +868,13 @@ export class MetamaskConnectEVM { * @param uri - The deeplink URI to be displayed as a QR code */ #onDisplayUri(uri: string): void { - logger('handler: display_uri', uri); - this.#provider.emit('display_uri', uri); - this.#eventHandlers?.displayUri?.(uri); - } - - /** - * Will trigger an accountsChanged event if there's a valid previous session. - * This is needed because the accountsChanged event is not triggered when - * revising, reloading or opening the app in a new tab. - * - * This works by checking by checking events received during MultichainCore initialization, - * and if there's a wallet_sessionChanged event, it will add a 1-time listener for eth_accounts results - * and trigger an accountsChanged event if the results are valid accounts. - */ - async #attemptSessionRecovery(): Promise { - // Skip session recovery if transport is not initialized yet. - // Transport is only initialized when there's a stored session or after connect() is called. - // Only attempt recovery if we're in a state where transport should be available. - if ( - this.#core.status !== 'connected' && - this.#core.status !== 'connecting' - ) { + if (this.#status !== 'connecting') { return; } - try { - const response = await this.#core.transport.request< - { method: 'wallet_getSession' }, - { - result: { sessionScopes: Caip25CaveatValue }; - id: number; - jsonrpc: '2.0'; - } - >({ - method: 'wallet_getSession', - }); - - const { sessionScopes } = response.result; - this.#sessionScopes = sessionScopes; - const permittedChainIds = getPermittedEthChainIds(sessionScopes); - - // Instead of using the accounts we get back from calling `wallet_getSession` - // we get permitted accounts from `eth_accounts` to make sure we have them ordered by last selected account - // and correctly set the currently selected account for the dapp - const permittedAccounts = await this.#core.transport.sendEip1193Message< - { method: 'eth_accounts'; params: [] }, - { result: Address[]; id: number; jsonrpc: '2.0' } - >({ method: 'eth_accounts', params: [] }); - - const chainId = await this.#getSelectedChainId(permittedChainIds); - - if (permittedChainIds.length && permittedAccounts.result) { - this.#onConnect({ - chainId, - accounts: permittedAccounts.result, - }); - } - } catch (error) { - console.error('Error attempting session recovery', error); - } + logger('handler: display_uri', uri); + this.#provider.emit('display_uri', uri); + this.#eventHandlers?.displayUri?.(uri); } /** diff --git a/packages/connect-evm/src/types.ts b/packages/connect-evm/src/types.ts index c420e9cf..b7e69962 100644 --- a/packages/connect-evm/src/types.ts +++ b/packages/connect-evm/src/types.ts @@ -7,7 +7,7 @@ export type CaipAccountId = `${string}:${string}:${string}`; export type CaipChainId = `${string}:${string}`; export type EIP1193ProviderEvents = { - connect: [{ chainId: Hex }]; + connect: [{ chainId: Hex; accounts: Address[] }]; disconnect: []; accountsChanged: [Address[]]; chainChanged: [Hex]; diff --git a/packages/connect-multichain/CHANGELOG.md b/packages/connect-multichain/CHANGELOG.md index c57fa49f..d51f024a 100644 --- a/packages/connect-multichain/CHANGELOG.md +++ b/packages/connect-multichain/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- When `ConnectMultichain.connect()` is called while a connection is already pending, the user is re-prompted with the pending connection deeplink. ([#176](https://github.com/MetaMask/connect-monorepo/pull/176)) + +### Changed + +- **BREAKING** `createMultichainClient()` now returns a singleton. Any incoming constructor params on subsequent calls to `createMultichainClient()` will be applied to the existing singleton instance except for the `dapp`, `storage`, and `ui.factory` param options. ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) +- **BREAKING** `ConnectMultichain.openDeeplinkIfNeeded()` is renamed to `openSimpleDeeplinkIfNeeded()` ([#176](https://github.com/MetaMask/connect-monorepo/pull/176)) +- `ConnectMultichain.connect()` now throws an `'Existing connection is pending. Please check your MetaMask Mobile app to continue.'` error if there is already an ongoing connection attempt. Previously it would abort that ongoing connection in favor of a new one. ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) +- `ConnectMultichain.connect()` adds newly requested scopes and accounts onto any existing permissions rather than fully replacing them. ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) +- `ConnectMultichain.disconnect()` accepts an optional array of scopes. When provided, only those scopes will be revoked from the existing permissions. If no scopes remain after a partial revoke, then the underly connection is fully discarded. If no scopes are specified ()`[]`), then all scopes will be removed. By default all scopes will be removed. ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) + ### Fixed - Fix `beforeunload` event listener not being properly removed on disconnect due to `.bind()` creating different function references, causing a listener leak on each connect/disconnect cycle ([#170](https://github.com/MetaMask/connect-monorepo/pull/170)) diff --git a/packages/connect-multichain/src/connect.test.ts b/packages/connect-multichain/src/connect.test.ts index fd28ca24..f50d32a0 100644 --- a/packages/connect-multichain/src/connect.test.ts +++ b/packages/connect-multichain/src/connect.test.ts @@ -118,6 +118,10 @@ function testSuite({ // Set the transport type as a string in storage (this is how it's stored) testOptions = { ...originalSdkOptions, + api: { + ...originalSdkOptions.api, + supportedNetworks: {}, + }, analytics: { ...originalSdkOptions.analytics, enabled: platform !== 'node', @@ -518,6 +522,15 @@ function testSuite({ ); sdk = await createSDK(testOptions); + + if (platform === 'web') { + mockedData.mockWalletRevokeSession.mockImplementation(async () => { + mockedData.mockWalletGetSession.mockResolvedValue({ + sessionScopes: {}, + }); + }); + } + await sdk.disconnect(); if (platform === 'web') { @@ -557,6 +570,14 @@ function testSuite({ t.expect(sdk.provider).toBeDefined(); t.expect(sdk.transport).toBeDefined(); + if (platform === 'web') { + mockedData.mockWalletRevokeSession.mockImplementation(async () => { + mockedData.mockWalletGetSession.mockResolvedValue({ + sessionScopes: {}, + }); + }); + } + await t .expect(sdk.disconnect()) .rejects.toThrow('Failed to disconnect transport'); diff --git a/packages/connect-multichain/src/domain/multichain/index.test.ts b/packages/connect-multichain/src/domain/multichain/index.test.ts new file mode 100644 index 00000000..e6ad9148 --- /dev/null +++ b/packages/connect-multichain/src/domain/multichain/index.test.ts @@ -0,0 +1,308 @@ +/* eslint-disable id-length -- vitest alias */ +import type { MultichainApiClient } from '@metamask/multichain-api-client'; +import type { Json } from '@metamask/utils'; +import * as t from 'vitest'; + +import { MultichainCore, TransportType, type ConnectionStatus } from '.'; +import type { RPCAPI, RpcUrlsMap } from './api/types'; +import type { + ExtendedTransport, + MergeableMultichainOptions, + MultichainOptions, +} from './types'; +import type { StoreClient } from '../store/client'; + +class MockMultichainCore extends MultichainCore { + storage = {} as StoreClient; + + status: ConnectionStatus = 'loaded'; + + provider = {} as MultichainApiClient; + + transport = {} as ExtendedTransport; + + transportType = TransportType.UNKNOWN; + + connect = async (): Promise => Promise.resolve(); + + disconnect = async (): Promise => Promise.resolve(); + + invokeMethod = async (): Promise => Promise.resolve({}); + + openSimpleDeeplinkIfNeeded = (): void => undefined; + + emitSessionChanged = async (): Promise => Promise.resolve(); + + /** + * Exposes options for test assertions. + * + * @returns Current merged options. + */ + getOptions(): MultichainOptions { + return this.options; + } +} + +/** + * Creates base multichain options for tests. + * + * @returns Default MultichainOptions used in mergeOptions tests. + */ +function createBaseOptions(): MultichainOptions { + return { + dapp: { name: 'Test Dapp', url: 'https://example.com' }, + api: { + supportedNetworks: { + 'eip155:1': 'https://eth.mainnet.example', + 'eip155:11155111': 'https://eth.sepolia.example', + } as RpcUrlsMap, + }, + storage: {} as StoreClient, + ui: { + factory: {} as MultichainOptions['ui']['factory'], + headless: false, + preferExtension: true, + showInstallModal: false, + }, + mobile: { + useDeeplink: false, + }, + transport: { + extensionId: 'ext-123', + }, + debug: false, + }; +} + +t.describe('MultichainCore', () => { + t.describe('mergeOptions', () => { + t.it('merges api.supportedNetworks shallowly over existing', () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({ + api: { + supportedNetworks: { + 'eip155:1': 'https://overridden.example', + 'solana:mainnet': 'https://solana.example', + }, + }, + }); + + const opts = core.getOptions(); + t.expect(opts.api.supportedNetworks['eip155:1']).toBe( + 'https://overridden.example', + ); + t.expect(opts.api.supportedNetworks['eip155:11155111']).toBe( + 'https://eth.sepolia.example', + ); + t.expect(opts.api.supportedNetworks['solana:mainnet']).toBe( + 'https://solana.example', + ); + }); + + t.it( + 'leaves api.supportedNetworks unchanged when partial.api is omitted', + () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({}); + + const opts = core.getOptions(); + t.expect(opts.api.supportedNetworks).toEqual( + base.api.supportedNetworks, + ); + }, + ); + + t.it( + 'leaves api.supportedNetworks unchanged when partial.api.supportedNetworks is empty', + () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({ api: { supportedNetworks: {} } }); + + const opts = core.getOptions(); + t.expect(opts.api.supportedNetworks).toEqual( + base.api.supportedNetworks, + ); + }, + ); + + t.it( + 'merges ui.headless, preferExtension, showInstallModal from partial', + () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({ + ui: { + headless: true, + preferExtension: false, + showInstallModal: true, + }, + }); + + const opts = core.getOptions(); + t.expect(opts.ui.headless).toBe(true); + t.expect(opts.ui.preferExtension).toBe(false); + t.expect(opts.ui.showInstallModal).toBe(true); + t.expect(opts.ui.factory).toBe(base.ui.factory); + }, + ); + + t.it('keeps existing ui values when partial.ui fields are omitted', () => { + const base = createBaseOptions(); + base.ui.headless = true; + base.ui.preferExtension = false; + const core = new MockMultichainCore(base); + + core.mergeOptions({ ui: {} }); + + const opts = core.getOptions(); + t.expect(opts.ui.headless).toBe(true); + t.expect(opts.ui.preferExtension).toBe(false); + t.expect(opts.ui.showInstallModal).toBe(false); + }); + + t.it('merges mobile options over existing', () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({ + mobile: { + useDeeplink: true, + }, + }); + + const opts = core.getOptions(); + t.expect(opts.mobile?.useDeeplink).toBe(true); + t.expect(opts.mobile).toEqual({ useDeeplink: true }); + }); + + t.it('leaves mobile unchanged when partial.mobile is omitted', () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({}); + + const opts = core.getOptions(); + t.expect(opts.mobile).toEqual(base.mobile); + }); + + t.it('merges transport.extensionId from partial', () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({ + transport: { extensionId: 'new-ext-456' }, + }); + + const opts = core.getOptions(); + t.expect(opts.transport?.extensionId).toBe('new-ext-456'); + }); + + t.it( + 'preserves existing transport when partial.transport is omitted', + () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({}); + + const opts = core.getOptions(); + t.expect(opts.transport).toEqual(base.transport); + }, + ); + + t.it('sets transport when initial options had no transport', () => { + const base = createBaseOptions(); + delete base.transport; + const core = new MockMultichainCore(base); + + core.mergeOptions({ transport: { extensionId: 'new-ext' } }); + + const opts = core.getOptions(); + t.expect(opts.transport?.extensionId).toBe('new-ext'); + }); + + t.it('merges debug from partial', () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + core.mergeOptions({ debug: true }); + + const opts = core.getOptions(); + t.expect(opts.debug).toBe(true); + }); + + t.it('keeps existing debug when partial.debug is omitted', () => { + const base = createBaseOptions(); + base.debug = true; + const core = new MockMultichainCore(base); + + core.mergeOptions({}); + + const opts = core.getOptions(); + t.expect(opts.debug).toBe(true); + }); + + t.it('does not mutate dapp, storage, or analytics', () => { + const base = createBaseOptions(); + base.analytics = { integrationType: 'direct' }; + const core = new MockMultichainCore(base); + + core.mergeOptions({ + api: { supportedNetworks: { 'eip155:1': 'https://x.com' } }, + ui: { headless: true }, + debug: true, + }); + + const opts = core.getOptions(); + t.expect(opts.dapp).toBe(base.dapp); + t.expect(opts.storage).toBe(base.storage); + t.expect(opts.analytics).toEqual(base.analytics); + }); + + t.it('handles full partial merge correctly', () => { + const base = createBaseOptions(); + const core = new MockMultichainCore(base); + + const partial: MergeableMultichainOptions = { + api: { + supportedNetworks: { + 'eip155:1': 'https://merged-eth.example', + }, + }, + ui: { + headless: true, + preferExtension: false, + showInstallModal: true, + }, + mobile: { useDeeplink: true }, + transport: { extensionId: 'merged-ext' }, + debug: true, + }; + + core.mergeOptions(partial); + + const opts = core.getOptions(); + t.expect(opts.api.supportedNetworks['eip155:1']).toBe( + 'https://merged-eth.example', + ); + t.expect(opts.api.supportedNetworks['eip155:11155111']).toBe( + 'https://eth.sepolia.example', + ); + t.expect(opts.ui).toMatchObject({ + headless: true, + preferExtension: false, + showInstallModal: true, + }); + t.expect(opts.mobile).toEqual({ useDeeplink: true }); + t.expect(opts.transport?.extensionId).toBe('merged-ext'); + t.expect(opts.debug).toBe(true); + }); + }); +}); diff --git a/packages/connect-multichain/src/domain/multichain/index.ts b/packages/connect-multichain/src/domain/multichain/index.ts index 0aa16dd8..8f9a6df5 100644 --- a/packages/connect-multichain/src/domain/multichain/index.ts +++ b/packages/connect-multichain/src/domain/multichain/index.ts @@ -9,7 +9,11 @@ import type { CaipAccountId, Json } from '@metamask/utils'; import { EventEmitter, type SDKEvents } from '../events'; import type { StoreClient } from '../store/client'; import type { InvokeMethodOptions, RPCAPI, Scope } from './api/types'; -import type { MultichainOptions, ExtendedTransport } from './types'; +import type { + ExtendedTransport, + MergeableMultichainOptions, + MultichainOptions, +} from './types'; export type ConnectionStatus = | 'pending' @@ -58,7 +62,7 @@ export abstract class MultichainCore extends EventEmitter { * * @returns Promise that resolves when disconnection is complete */ - abstract disconnect(): Promise; + abstract disconnect(scopes?: Scope[]): Promise; /** * Invokes an RPC method with the specified options. @@ -68,11 +72,53 @@ export abstract class MultichainCore extends EventEmitter { */ abstract invokeMethod(options: InvokeMethodOptions): Promise; - abstract openDeeplinkIfNeeded(): void; + abstract openSimpleDeeplinkIfNeeded(): void; + + abstract emitSessionChanged(): Promise; - constructor(protected readonly options: MultichainOptions) { + constructor(protected options: MultichainOptions) { super(); } + + /** + * Merges the given options into the current instance options. + * Only the mergeable keys are updated (api.supportedNetworks, ui.*, mobile.*, transport.extensionId, debug). + * The main thing to note is that the value for `dapp` is not merged as it does not make sense for + * subsequent calls to `createMultichainClient` to have a different `dapp` value. + * Used when createMultichainClient is called with an existing singleton. + * + * @param partial - Options to merge/overwrite onto the current instance + */ + mergeOptions(partial: MergeableMultichainOptions): void { + const opts = this.options; + this.options = { + ...opts, + api: { + ...opts.api, + supportedNetworks: { + ...opts.api.supportedNetworks, + ...(partial.api?.supportedNetworks ?? {}), + }, + }, + ui: { + ...opts.ui, + headless: partial.ui?.headless ?? opts.ui.headless, + preferExtension: partial.ui?.preferExtension ?? opts.ui.preferExtension, + showInstallModal: + partial.ui?.showInstallModal ?? opts.ui.showInstallModal, + }, + mobile: { + ...opts.mobile, + ...(partial.mobile ?? {}), + }, + transport: { + ...(opts.transport ?? {}), + extensionId: + partial.transport?.extensionId ?? opts.transport?.extensionId, + }, + debug: partial.debug ?? opts.debug, + }; + } } /* c8 ignore end */ diff --git a/packages/connect-multichain/src/domain/multichain/types.ts b/packages/connect-multichain/src/domain/multichain/types.ts index 63086a8a..825bc013 100644 --- a/packages/connect-multichain/src/domain/multichain/types.ts +++ b/packages/connect-multichain/src/domain/multichain/types.ts @@ -89,6 +89,23 @@ type MultiChainFNOptions = Omit & { storage?: StoreClient; }; +/** + * Options that can be merged/overwritten when createMultichainClient is called + * with an existing singleton. + */ +export type MergeableMultichainOptions = Omit< + MultichainOptions, + 'dapp' | 'analytics' | 'storage' | 'api' | 'ui' | 'transport' +> & { + api?: MultichainOptions['api']; + ui?: Pick< + MultichainOptions['ui'], + 'headless' | 'preferExtension' | 'showInstallModal' + >; + transport?: Pick, 'extensionId'>; + debug?: boolean; +}; + /** * Complete options for Multichain SDK configuration. * @@ -118,4 +135,8 @@ export type ExtendedTransport = Omit & { ) => Promise; getActiveSession: () => Promise; + + getStoredSessionRequest: () => Promise; + + disconnect: (scopes: Scope[]) => Promise; }; diff --git a/packages/connect-multichain/src/init.test.ts b/packages/connect-multichain/src/init.test.ts index 55b30c0a..2ff964d5 100644 --- a/packages/connect-multichain/src/init.test.ts +++ b/packages/connect-multichain/src/init.test.ts @@ -57,6 +57,10 @@ function testSuite({ testOptions = { ...originalSdkOptions, ui: uiOptions, + api: { + ...originalSdkOptions.api, + supportedNetworks: {}, + }, analytics: { ...originalSdkOptions.analytics, enabled: platform === 'web' || platform === 'web-mobile', diff --git a/packages/connect-multichain/src/invoke.test.ts b/packages/connect-multichain/src/invoke.test.ts index 506e6770..3ab2192d 100644 --- a/packages/connect-multichain/src/invoke.test.ts +++ b/packages/connect-multichain/src/invoke.test.ts @@ -115,6 +115,10 @@ function testSuite({ // Set the transport type as a string in storage (this is how it's stored) testOptions = { ...originalSdkOptions, + api: { + ...originalSdkOptions.api, + supportedNetworks: {}, + }, analytics: { ...originalSdkOptions.analytics, enabled: platform !== 'node', diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index aa90ab9c..221f2b54 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -61,13 +61,21 @@ import { DefaultTransport } from './transports/default'; import { MultichainApiClientWrapperTransport } from './transports/multichainApiClientWrapper'; import { MWPTransport } from './transports/mwp'; import { keymanager } from './transports/mwp/KeyManager'; -import { getDappId, openDeeplink, setupDappMetadata } from './utils'; +import { + getDappId, + getGlobalObject, + mergeRequestedSessionWithExisting, + openDeeplink, + setupDappMetadata, +} from './utils'; export { getInfuraRpcUrls } from '../domain/multichain/api/infura'; // ENFORCE NAMESPACE THAT CAN BE DISABLED const logger = createLogger('metamask-sdk:core'); +const SINGLETON_KEY = '__METAMASK_CONNECT_MULTICHAIN_SINGLETON__'; + export class MetaMaskConnectMultichain extends MultichainCore { readonly #provider: MultichainApiClient; @@ -152,19 +160,49 @@ export class MetaMaskConnectMultichain extends MultichainCore { }); } + // Creates a singleton instance of MetaMaskConnectMultichain. + // If the singleton already exists, it merges the incoming options with the + // existing singleton options for the following keys: `api.supportedNetworks`, + // `ui.*`, `mobile.*`, `transport.extensionId`, `debug`. Take note that the + // value for `dapp` is not merged as it does not make sense for subsequent calls to + // `createMultichainClient` to have a different `dapp` value. static async create( options: MultichainOptions, ): Promise { - const instance = new MetaMaskConnectMultichain(options); - const isEnabled = await isLoggerEnabled( - 'metamask-sdk:core', - instance.options.storage, - ); - if (isEnabled) { - enableDebug('metamask-sdk:core'); + const globalObject = getGlobalObject(); + const existing = globalObject[SINGLETON_KEY] as + | Promise + | undefined; + if (existing) { + const instance = await existing; + instance.mergeOptions(options); + if (options.debug) { + enableDebug('metamask-sdk:*'); + } + return instance; } - await instance.#init(); - return instance; + + const instancePromise = (async (): Promise => { + const instance = new MetaMaskConnectMultichain(options); + const isEnabled = await isLoggerEnabled( + 'metamask-sdk:core', + instance.options.storage, + ); + if (isEnabled) { + enableDebug('metamask-sdk:core'); + } + await instance.#init(); + return instance; + })(); + + globalObject[SINGLETON_KEY] = instancePromise; + + instancePromise.catch((error) => { + globalObject[SINGLETON_KEY] = undefined; + console.error('Error initializing MetaMaskConnectMultichain', error); + }); + + return instancePromise; } async #setupAnalytics(): Promise { @@ -215,7 +253,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { if (hasExtensionInstalled) { const apiTransport = new DefaultTransport(); this.#transport = apiTransport; - this.#providerTransportWrapper.setupNotifcationListener(); + this.#providerTransportWrapper.setupTransportNotificationListener(); this.#listener = apiTransport.onNotification( this.#onTransportNotification.bind(this), ); @@ -227,7 +265,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { const apiTransport = new MWPTransport(dappClient, kvstore); this.#dappClient = dappClient; this.#transport = apiTransport; - this.#providerTransportWrapper.setupNotifcationListener(); + this.#providerTransportWrapper.setupTransportNotificationListener(); this.#listener = apiTransport.onNotification( this.#onTransportNotification.bind(this), ); @@ -260,25 +298,16 @@ export class MetaMaskConnectMultichain extends MultichainCore { async #init(): Promise { try { - // @ts-expect-error mmsdk should be accessible - if (typeof window !== 'undefined' && window.mmsdk?.isInitialized) { - logger('MetaMaskSDK: init already initialized'); - } else { - await this.#setupAnalytics(); - await this.#setupTransport(); - try { - const baseProps = await getBaseAnalyticsProperties( - this.options, - this.storage, - ); - analytics.track('mmconnect_initialized', baseProps); - } catch (error) { - logger('Error tracking initialized event', error); - } - if (typeof window !== 'undefined') { - // @ts-expect-error mmsdk should be accessible - window.mmsdk = this; - } + await this.#setupAnalytics(); + await this.#setupTransport(); + try { + const baseProps = await getBaseAnalyticsProperties( + this.options, + this.storage, + ); + analytics.track('mmconnect_initialized', baseProps); + } catch (error) { + logger('Error tracking initialized event', error); } } catch (error) { await this.storage.removeTransport(); @@ -314,7 +343,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { this.#dappClient = dappClient; const apiTransport = new MWPTransport(dappClient, kvstore); this.#transport = apiTransport; - this.#providerTransportWrapper.setupNotifcationListener(); + this.#providerTransportWrapper.setupTransportNotificationListener(); this.#listener = this.transport.onNotification( this.#onTransportNotification.bind(this), ); @@ -535,7 +564,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { this.#onTransportNotification.bind(this), ); this.#transport = transport; - this.#providerTransportWrapper.setupNotifcationListener(); + this.#providerTransportWrapper.setupTransportNotificationListener(); return transport; } @@ -574,7 +603,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { if (this.transport.isConnected()) { timeout = setTimeout(() => { - this.openDeeplinkIfNeeded(); + this.openSimpleDeeplinkIfNeeded(); }, 250); } else { this.dappClient.once( @@ -683,8 +712,14 @@ export class MetaMaskConnectMultichain extends MultichainCore { sessionProperties?: SessionProperties, forceRequest?: boolean, ): Promise { - if (this.status !== 'connected') { - await this.disconnect(); + if ( + this.status === 'connecting' && + this.transportType === TransportType.MWP + ) { + await this.#openConnectDeeplinkIfNeeded(); + throw new Error( + 'Existing connection is pending. Please check your MetaMask Mobile app to continue.', + ); } const { ui } = this.options; const platformType = getPlatformType(); @@ -724,19 +759,29 @@ export class MetaMaskConnectMultichain extends MultichainCore { logger('Error tracking connection_initiated event', error); } + const sessionData = await this.#getCaipSession(); + + const { mergedScopes, mergedCaipAccountIds, mergedSessionProperties } = + mergeRequestedSessionWithExisting( + sessionData, + scopes, + caipAccountIds, + sessionProperties, + ); + // Needed because empty object will cause wallet_createSession to return an error - const nonEmptySessionProperites = - Object.keys(sessionProperties ?? {}).length > 0 - ? sessionProperties + const nonEmptySessionProperties = + Object.keys(mergedSessionProperties ?? {}).length > 0 + ? mergedSessionProperties : undefined; if (this.#transport?.isConnected() && !secure) { return this.#handleConnection( this.#transport .connect({ - scopes, - caipAccountIds, - sessionProperties: nonEmptySessionProperites, + scopes: mergedScopes, + caipAccountIds: mergedCaipAccountIds, + sessionProperties: nonEmptySessionProperties, forceRequest, }) .then(async () => { @@ -755,9 +800,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { const defaultTransport = await this.#setupDefaultTransport(); return this.#handleConnection( defaultTransport.connect({ - scopes, - caipAccountIds, - sessionProperties: nonEmptySessionProperites, + scopes: mergedScopes, + caipAccountIds: mergedCaipAccountIds, + sessionProperties: nonEmptySessionProperties, forceRequest, }), scopes, @@ -771,9 +816,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { // Web transport has no initial payload return this.#handleConnection( defaultTransport.connect({ - scopes, - caipAccountIds, - sessionProperties: nonEmptySessionProperites, + scopes: mergedScopes, + caipAccountIds: mergedCaipAccountIds, + sessionProperties: nonEmptySessionProperties, forceRequest, }), scopes, @@ -793,9 +838,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { // Desktop is not preferred option, so we use deeplinks (mobile web) return this.#handleConnection( this.#deeplinkConnect( - scopes, - caipAccountIds, - nonEmptySessionProperites, + mergedScopes, + mergedCaipAccountIds, + nonEmptySessionProperties, ), scopes, transportType, @@ -806,9 +851,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { return this.#handleConnection( this.#showInstallModal( shouldShowInstallModal, - scopes, - caipAccountIds, - nonEmptySessionProperites, + mergedScopes, + mergedCaipAccountIds, + nonEmptySessionProperties, ), scopes, transportType, @@ -820,20 +865,47 @@ export class MetaMaskConnectMultichain extends MultichainCore { super.emit(event, args); } - async disconnect(): Promise { - await this.#listener?.(); - this.#beforeUnloadListener?.(); + async #getCaipSession(): Promise { + let sessionData: SessionData = { + sessionScopes: {}, + sessionProperties: {}, + }; + if (this.status === 'connected') { + const response = await this.transport.request({ + method: 'wallet_getSession', + }); + if (response.result) { + sessionData = response.result as SessionData; + } + } + return sessionData; + } + + async disconnect(scopes: Scope[] = []): Promise { + const sessionData = await this.#getCaipSession(); + + const remainingScopes = + scopes.length === 0 + ? [] + : Object.keys(sessionData.sessionScopes).filter( + (scope) => !scopes.includes(scope as Scope), + ); - await this.#transport?.disconnect(); - await this.storage.removeTransport(); + await this.#transport?.disconnect(scopes); - this.emit('stateChanged', 'disconnected'); + if (remainingScopes.length === 0) { + await this.#listener?.(); + this.#beforeUnloadListener?.(); - this.#listener = undefined; - this.#beforeUnloadListener = undefined; - this.#transport = undefined; - this.#providerTransportWrapper.clearNotificationCallbacks(); - this.#dappClient = undefined; + await this.storage.removeTransport(); + + this.#listener = undefined; + this.#beforeUnloadListener = undefined; + this.#transport = undefined; + this.#providerTransportWrapper.clearTransportNotificationListener(); + this.#dappClient = undefined; + this.status = 'disconnected'; + } } async invokeMethod(request: InvokeMethodOptions): Promise { @@ -846,7 +918,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { } // DRY THIS WITH REQUEST ROUTER - openDeeplinkIfNeeded(): void { + openSimpleDeeplinkIfNeeded(): void { const { ui, mobile } = this.options; const { showInstallModal = false } = ui ?? {}; const secure = isSecure(); @@ -868,4 +940,62 @@ export class MetaMaskConnectMultichain extends MultichainCore { }, 10); // small delay to ensure the message encryption and dispatch completes } } + + async #openConnectDeeplinkIfNeeded(): Promise { + const { ui } = this.options; + const { showInstallModal = false } = ui ?? {}; + const secure = isSecure(); + const shouldOpenDeeplink = secure && !showInstallModal; + + if (!shouldOpenDeeplink) { + return; + } + + const storedSessionRequest = + await this.#transport?.getStoredSessionRequest(); + if (!storedSessionRequest) { + return; + } + + const connectionRequest = { + sessionRequest: storedSessionRequest, + metadata: { + dapp: this.options.dapp, + sdk: { version: getVersion(), platform: getPlatformType() }, + }, + }; + const deeplink = + this.options.ui.factory.createConnectionDeeplink(connectionRequest); + + const universalLink = + this.options.ui.factory.createConnectionUniversalLink(connectionRequest); + + if (this.options.mobile?.preferredOpenLink) { + this.options.mobile.preferredOpenLink(deeplink, '_self'); + } else { + openDeeplink(this.options, deeplink, universalLink); + } + } + + // Provides a way for ecosystem clients (EVM, Solana, etc.) to get the current CAIP session data + // when instantiating themselves (as they would have already missed any initial sessionChanged events emitted by ConnectMultichain) + // without having to concern themselves with the current transport connection status. + async emitSessionChanged(): Promise { + const emptySession = { sessionScopes: {} }; + + if (this.status !== 'connected' && this.status !== 'connecting') { + // If we aren't connected or connecting, there definitely is no active CAIP session + // so we optimistically emit an empty session to signify that to the ecosystem client consumers (EVM, Solana, etc.) + this.emit('wallet_sessionChanged', emptySession); + return; + } + + // Otherwise, we need to fetch the current CAIP session from the wallet + const response = await this.transport.request({ + method: 'wallet_getSession', + }); + + // And then simulate a sessionChanged event with the current CAIP session data + this.emit('wallet_sessionChanged', response.result ?? emptySession); + } } diff --git a/packages/connect-multichain/src/multichain/transports/default/index.ts b/packages/connect-multichain/src/multichain/transports/default/index.ts index bd9d139d..f57dc48b 100644 --- a/packages/connect-multichain/src/multichain/transports/default/index.ts +++ b/packages/connect-multichain/src/multichain/transports/default/index.ts @@ -1,4 +1,5 @@ import type { Session } from '@metamask/mobile-wallet-protocol-core'; +import type { SessionRequest } from '@metamask/mobile-wallet-protocol-dapp-client'; import { type SessionProperties, type CreateSessionParams, @@ -229,10 +230,6 @@ export class DefaultTransport implements ExtendedTransport { ); if (!hasSameScopesAndAccounts) { - await this.request( - { method: 'wallet_revokeSession', params: walletSession }, - this.#defaultRequestOptions, - ); const response = await this.request( { method: 'wallet_createSession', params: createSessionParams }, this.#defaultRequestOptions, @@ -258,10 +255,17 @@ export class DefaultTransport implements ExtendedTransport { }); } - async disconnect(): Promise { - this.#notificationCallbacks.clear(); + async disconnect(scopes: Scope[] = []): Promise { + await this.request({ method: 'wallet_revokeSession', params: { scopes } }); + + const response = await this.request({ method: 'wallet_getSession' }); + const { sessionScopes } = response.result as SessionData; + + if (Object.keys(sessionScopes).length > 0) { + return; + } - await this.request({ method: 'wallet_revokeSession', params: {} }); + this.#notificationCallbacks.clear(); // Remove the message listener when disconnecting if (this.#handleResponseListener) { @@ -284,7 +288,7 @@ export class DefaultTransport implements ExtendedTransport { } this.#pendingRequests.clear(); - return this.#transport.disconnect(); + await this.#transport.disconnect(); } isConnected(): boolean { @@ -317,4 +321,10 @@ export class DefaultTransport implements ExtendedTransport { 'getActiveSession is purposely not implemented for the DefaultTransport', ); } + + async getStoredSessionRequest(): Promise { + throw new Error( + 'getStoredSessionRequest is purposely not implemented for the DefaultTransport', + ); + } } diff --git a/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts b/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts index 10a6e1f9..a18c16c7 100644 --- a/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts +++ b/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts @@ -2,9 +2,10 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type -- Inferred types are sufficient */ /* eslint-disable @typescript-eslint/parameter-properties -- Constructor shorthand is intentional */ /* eslint-disable no-plusplus -- Increment operator is safe here */ -/* eslint-disable @typescript-eslint/no-floating-promises -- Promise is intentionally not awaited */ + import type { CreateSessionParams, + RevokeSessionParams, Transport, TransportRequest, TransportResponse, @@ -31,6 +32,8 @@ export class MultichainApiClientWrapperTransport implements Transport { readonly #notificationCallbacks = new Set<(data: unknown) => void>(); + notificationListener: (() => void) | undefined; + constructor( private readonly metamaskConnectMultichain: MetaMaskConnectMultichain, ) {} @@ -53,16 +56,24 @@ export class MultichainApiClientWrapperTransport implements Transport { }); } - setupNotifcationListener(): void { - this.metamaskConnectMultichain.transport.onNotification( - this.notifyCallbacks.bind(this), - ); + clearTransportNotificationListener(): void { + this.notificationListener?.(); + this.notificationListener = undefined; + } + + setupTransportNotificationListener(): void { + if (!this.isTransportDefined() || this.notificationListener) { + return; + } + this.notificationListener = + this.metamaskConnectMultichain.transport.onNotification( + this.notifyCallbacks.bind(this), + ); } async connect(): Promise { console.log('📚 connect'); - // noop - return Promise.resolve(); + await this.metamaskConnectMultichain.emitSessionChanged(); } async disconnect(): Promise { @@ -104,14 +115,11 @@ export class MultichainApiClientWrapperTransport implements Transport { } onNotification(callback: (data: unknown) => void): () => void { - if (!this.isTransportDefined()) { - this.#notificationCallbacks.add(callback); - return () => { - this.#notificationCallbacks.delete(callback); - }; - } - - return this.metamaskConnectMultichain.transport.onNotification(callback); + this.setupTransportNotificationListener(); + this.#notificationCallbacks.add(callback); + return () => { + this.#notificationCallbacks.delete(callback); + }; } async #walletCreateSession(request: TransportRequestWithId) { @@ -168,8 +176,13 @@ export class MultichainApiClientWrapperTransport implements Transport { return { jsonrpc: '2.0', id: request.id, result: true }; } + const revokeSessionParams = request.params as + | RevokeSessionParams + | undefined; + const scopes = revokeSessionParams?.scopes ?? []; + try { - this.metamaskConnectMultichain.disconnect(); + await this.metamaskConnectMultichain.disconnect(scopes as Scope[]); return { jsonrpc: '2.0', id: request.id, result: true }; } catch (_error) { return { jsonrpc: '2.0', id: request.id, result: false }; diff --git a/packages/connect-multichain/src/multichain/transports/mwp/index.ts b/packages/connect-multichain/src/multichain/transports/mwp/index.ts index 3c30e903..198827a5 100644 --- a/packages/connect-multichain/src/multichain/transports/mwp/index.ts +++ b/packages/connect-multichain/src/multichain/transports/mwp/index.ts @@ -53,6 +53,7 @@ const DEFAULT_RESUME_TIMEOUT = 10 * 1000; const SESSION_STORE_KEY = 'cache_wallet_getSession'; const ACCOUNTS_STORE_KEY = 'cache_eth_accounts'; const CHAIN_STORE_KEY = 'cache_eth_chainId'; +const PENDING_SESSION_REQUEST_KEY = 'pending_session_request'; const CACHED_METHOD_LIST = [ 'wallet_getSession', @@ -115,6 +116,14 @@ export class MWPTransport implements ExtendedTransport { }, ) { this.dappClient.on('message', this.handleMessage.bind(this)); + this.dappClient.on('session_request', (sessionRequest: SessionRequest) => { + this.currentSessionRequest = sessionRequest; + this.kvstore + .set(PENDING_SESSION_REQUEST_KEY, JSON.stringify(sessionRequest)) + .catch((err) => { + logger('Failed to store pending session request', err); + }); + }); if ( typeof window !== 'undefined' && typeof window.addEventListener !== 'undefined' @@ -124,6 +133,27 @@ export class MWPTransport implements ExtendedTransport { } } + private async removeStoredSessionRequest(): Promise { + await this.kvstore.delete(PENDING_SESSION_REQUEST_KEY); + } + + /** + * Returns the stored pending session request from the dappClient session_request event, if any. + * + * @returns The stored SessionRequest, or null if none or invalid. + */ + async getStoredSessionRequest(): Promise { + try { + const raw = await this.kvstore.get(PENDING_SESSION_REQUEST_KEY); + if (!raw) { + return null; + } + return JSON.parse(raw) as SessionRequest; + } catch { + return null; + } + } + private onWindowFocus(): void { if (!this.isConnected()) { this.dappClient.reconnect(); @@ -324,6 +354,7 @@ export class MWPTransport implements ExtendedTransport { } walletSession = response.result as SessionData; } + await this.removeStoredSessionRequest(); this.notifyCallbacks({ method: 'wallet_sessionChanged', params: walletSession, @@ -459,6 +490,7 @@ export class MWPTransport implements ExtendedTransport { request, messagePayload as TransportResponse, ); + await this.removeStoredSessionRequest(); this.notifyCallbacks(messagePayload); return resolveConnection(); }; @@ -508,28 +540,87 @@ export class MWPTransport implements ExtendedTransport { this.dappClient.off('message', initialConnectionMessageHandler); initialConnectionMessageHandler = undefined; } + this.removeStoredSessionRequest(); }); } /** * Disconnects from the Mobile Wallet Protocol * + * @param [scopes] - The scopes to revoke. If not provided or empty, all scopes will be revoked. * @returns Nothing */ - async disconnect(): Promise { - // Clean up window focus event listener - if ( - typeof window !== 'undefined' && - typeof window.removeEventListener !== 'undefined' && - this.windowFocusHandler - ) { - window.removeEventListener('focus', this.windowFocusHandler); - this.windowFocusHandler = undefined; + async disconnect(scopes: Scope[] = []): Promise { + const cachedSession = await this.getCachedResponse({ + jsonrpc: '2.0', + id: '0', + method: 'wallet_getSession', + }); + const cachedSessionScopes = + (cachedSession?.result as SessionData | undefined)?.sessionScopes ?? {}; + + const remainingScopes = + scopes.length === 0 + ? [] + : Object.keys(cachedSessionScopes).filter( + (scope) => !scopes.includes(scope as Scope), + ); + + const newSessionScopes = Object.fromEntries( + Object.entries(cachedSessionScopes).filter(([key]) => + remainingScopes.includes(key), + ), + ); + + // NOTE: Purposely not awaiting this to avoid blocking the disconnect flow. + // This might not actually get executed on the wallet if the user doesn't open + // their wallet before the message TTL or if the underlying transport isn't actually connected + this.request({ method: 'wallet_revokeSession', params: { scopes } }).catch( + (err) => { + console.error('error revoking session', err); + }, + ); + + // Clear the cached values for eth_accounts and eth_chainId if all eip155 scopes were removed. + const remainingScopesIncludeEip155 = remainingScopes.some((scope) => + scope.includes('eip155'), + ); + if (!remainingScopesIncludeEip155) { + this.kvstore.delete(ACCOUNTS_STORE_KEY); + this.kvstore.delete(CHAIN_STORE_KEY); } - this.kvstore.delete(SESSION_STORE_KEY); - this.kvstore.delete(ACCOUNTS_STORE_KEY); - this.kvstore.delete(CHAIN_STORE_KEY); - return this.dappClient.disconnect(); + + if (remainingScopes.length > 0) { + this.kvstore.set( + SESSION_STORE_KEY, + JSON.stringify({ + result: { + sessionScopes: newSessionScopes, + }, + }), + ); + } else { + this.kvstore.delete(SESSION_STORE_KEY); + + // Clean up window focus event listener + if ( + typeof window !== 'undefined' && + typeof window.removeEventListener !== 'undefined' && + this.windowFocusHandler + ) { + window.removeEventListener('focus', this.windowFocusHandler); + this.windowFocusHandler = undefined; + } + + await this.dappClient.disconnect(); + } + + this.notifyCallbacks({ + method: 'wallet_sessionChanged', + params: { + sessionScopes: newSessionScopes, + }, + }); } /** @@ -736,6 +827,7 @@ export class MWPTransport implements ExtendedTransport { const timeoutPromise = new Promise((_resolve, reject) => { setTimeout(() => { unsubscribe(); + this.removeStoredSessionRequest(); reject(new TransportTimeoutError()); }, this.options.resumeTimeout); }); diff --git a/packages/connect-multichain/src/multichain/utils/index.ts b/packages/connect-multichain/src/multichain/utils/index.ts index aecb2a6a..21f1743b 100644 --- a/packages/connect-multichain/src/multichain/utils/index.ts +++ b/packages/connect-multichain/src/multichain/utils/index.ts @@ -2,6 +2,7 @@ /* eslint-disable jsdoc/require-param-description -- Auto-generated JSDoc */ /* eslint-disable jsdoc/require-returns -- Auto-generated JSDoc */ /* eslint-disable @typescript-eslint/explicit-function-return-type -- Inferred types are sufficient */ +import type { SessionProperties } from '@metamask/multichain-api-client'; import { type CaipAccountId, type CaipChainId, @@ -21,6 +22,27 @@ import { export type OptionalScopes = Record; +/** + * Returns the global object for the current JS environment. + * + * @returns The global object as a record for indexing + */ +export function getGlobalObject(): Record { + if (typeof globalThis !== 'undefined') { + return globalThis as unknown as Record; + } + if (typeof global !== 'undefined') { + return global as unknown as Record; + } + if (typeof self !== 'undefined') { + return self as unknown as Record; + } + if (typeof window !== 'undefined') { + return window as unknown as Record; + } + throw new Error('Unable to locate global object'); +} + /** * Cross-platform base64 encoding * Works in browser, Node.js, and React Native environments @@ -97,6 +119,53 @@ export function openDeeplink( } } +/** + * Merges existing session (from getCaipSession) with newly requested scopes, accounts, and session properties. + * Derives existing scopes/accounts from sessionData.sessionScopes, then merges with requested values. + * + * @param sessionData - Current CAIP session data + * @param scopes - Newly requested scopes + * @param caipAccountIds - Newly requested account IDs + * @param sessionProperties - New session properties to merge over existing + * @returns requestedScopes, requestedCaipAccountIds, and requestedSessionProperties + */ +export function mergeRequestedSessionWithExisting( + sessionData: SessionData, + scopes: Scope[], + caipAccountIds: CaipAccountId[], + sessionProperties?: SessionProperties, +): { + mergedScopes: Scope[]; + mergedCaipAccountIds: CaipAccountId[]; + mergedSessionProperties: SessionProperties; +} { + const existingCaipChainIds = Object.keys(sessionData.sessionScopes); + const existingCaipAccountIds: string[] = []; + Object.values(sessionData.sessionScopes).forEach((scopeObject) => { + if (scopeObject?.accounts && Array.isArray(scopeObject.accounts)) { + scopeObject.accounts.forEach((account) => { + existingCaipAccountIds.push(account); + }); + } + }); + + const mergedScopes = Array.from( + new Set([...existingCaipChainIds, ...scopes]), + ) as Scope[]; + const mergedCaipAccountIds = Array.from( + new Set([...existingCaipAccountIds, ...caipAccountIds]), + ) as CaipAccountId[]; + const mergedSessionProperties = { + ...sessionData.sessionProperties, + ...sessionProperties, + }; + return { + mergedScopes, + mergedCaipAccountIds, + mergedSessionProperties, + }; +} + /** * * @param scopes diff --git a/packages/connect-multichain/src/polyfills/buffer-shim.ts b/packages/connect-multichain/src/polyfills/buffer-shim.ts index 9fa04274..a7963a12 100644 --- a/packages/connect-multichain/src/polyfills/buffer-shim.ts +++ b/packages/connect-multichain/src/polyfills/buffer-shim.ts @@ -1,6 +1,3 @@ -/* eslint-disable no-restricted-globals -- Polyfill intentionally uses global/window */ -/* eslint-disable no-negated-condition -- Ternary chain is clearer here */ -/* eslint-disable no-nested-ternary -- Environment detection requires chained ternary */ /* eslint-disable import-x/no-nodejs-modules -- Buffer polyfill requires Node.js module */ /** * Buffer polyfill for browser and React Native environments. @@ -12,17 +9,8 @@ */ import { Buffer } from 'buffer'; -// Get the appropriate global object for the current environment -const globalObj = - typeof globalThis !== 'undefined' - ? globalThis - : typeof global !== 'undefined' - ? global - : typeof window !== 'undefined' - ? window - : ({} as typeof globalThis); +import { getGlobalObject } from '../multichain/utils'; // Only set Buffer if it's not already defined (avoid overwriting Node.js native Buffer) -if (!globalObj.Buffer) { - globalObj.Buffer = Buffer; -} +const globalObj = getGlobalObject(); +globalObj.Buffer ??= Buffer; diff --git a/packages/connect-multichain/src/session.test.ts b/packages/connect-multichain/src/session.test.ts index 70799385..242428f0 100644 --- a/packages/connect-multichain/src/session.test.ts +++ b/packages/connect-multichain/src/session.test.ts @@ -63,6 +63,10 @@ function testSuite({ // Set the transport type as a string in storage (this is how it's stored) testOptions = { ...originalSdkOptions, + api: { + ...originalSdkOptions.api, + supportedNetworks: {}, + }, analytics: { ...originalSdkOptions.analytics, enabled: platform !== 'node', @@ -161,17 +165,8 @@ function testSuite({ t.expect.objectContaining({ method: 'wallet_getSession', }), - - { timeout: 60 * 1000 }, - ); - t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( - t.expect.objectContaining({ - method: 'wallet_revokeSession', - params: mockSessionData, - }), { timeout: 60 * 1000 }, ); - t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( t.expect.objectContaining({ method: 'wallet_createSession', diff --git a/packages/connect-multichain/tests/fixtures.test.ts b/packages/connect-multichain/tests/fixtures.test.ts index 2af9cae6..e8b8922e 100644 --- a/packages/connect-multichain/tests/fixtures.test.ts +++ b/packages/connect-multichain/tests/fixtures.test.ts @@ -12,7 +12,7 @@ /* eslint-disable import-x/order -- Mock imports need specific order */ /* eslint-disable @typescript-eslint/no-use-before-define -- Function hoisting */ /* eslint-disable @typescript-eslint/prefer-promise-reject-errors -- Test rejection patterns */ -/* eslint-disable jsdoc/require-returns -- Test helpers */ + /* eslint-disable no-plusplus -- Test loops */ /* eslint-disable no-invalid-this -- Test context */ /* eslint-disable no-useless-catch -- Test error handling */ @@ -53,6 +53,7 @@ import type { CreateTestFN, } from './types'; import type { MultichainOptions } from '../src/domain'; +import { getGlobalObject } from '../src/multichain/utils'; // Import createSDK functions for convenience import { createMultichainClient as createMetamaskSDKWeb } from '../src/index.browser'; @@ -173,8 +174,20 @@ export const createTest: CreateTestFN = ({ /** * */ + /** Singleton key used by MetaMaskConnectMultichain.create() - clear so each test gets a fresh instance */ + const MULTICHAIN_SINGLETON_KEY = '__METAMASK_CONNECT_MULTICHAIN_SINGLETON__'; + + /** + * Sets up mocks and clears singleton before each test. + * + * @returns Promise resolving to the mocked data for the test. + */ async function beforeEach() { try { + // Clear singleton so each test gets a new SDK instance (avoids state leaking between tests) + const globalObject = getGlobalObject(); + globalObject[MULTICHAIN_SINGLETON_KEY] = undefined; + pendingRequests = new Map(); nativeStorageStub.data.clear(); diff --git a/playground/browser-playground/CHANGELOG.md b/playground/browser-playground/CHANGELOG.md index bc327176..e0b0e7cd 100644 --- a/playground/browser-playground/CHANGELOG.md +++ b/playground/browser-playground/CHANGELOG.md @@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add disconnect buttons to cards ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) + ### Changed - Remove manual registration of `connect-solana` ([#178](https://github.com/MetaMask/connect-monorepo/pull/178)) +### Removed + +- Remove the explicit ActiveProviderStorage pattern. Now all providers (cards) are "Active" even without user input to connect to a specific ecosystem ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) + +### Fixed + +- Fixes EVM Provider setChainId and setAccounts to also use the connect event ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) +- Multichain Card and button properly reflect initial instantiation connecting status ([#157](https://github.com/MetaMask/connect-monorepo/pull/157)) + ## [0.2.0] ### Added diff --git a/playground/browser-playground/src/App.tsx b/playground/browser-playground/src/App.tsx index 03a334be..9cb0395d 100644 --- a/playground/browser-playground/src/App.tsx +++ b/playground/browser-playground/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import type { Scope, SessionData } from '@metamask/connect-multichain'; import { hexToNumber, type CaipAccountId, type Hex } from '@metamask/utils'; -import { useAccount, useChainId, useConnect, useDisconnect } from 'wagmi'; +import { useAccount, useConnect, useDisconnect } from 'wagmi'; import { useWallet } from '@solana/wallet-adapter-react'; import { FEATURED_NETWORKS, @@ -14,11 +14,6 @@ import DynamicInputs, { INPUT_LABEL_TYPE } from './components/DynamicInputs'; import { ScopeCard } from './components/ScopeCard'; import { LegacyEVMCard } from './components/LegacyEVMCard'; import { WagmiCard } from './components/WagmiCard'; -import { - isProviderActive, - setProviderActive, - clearAllActiveProviders, -} from './utils/activeProviderStorage'; import { SolanaWalletCard } from './components/SolanaWalletCard'; import { useSolanaSDK } from './sdk/SolanaProvider'; import { Buffer } from 'buffer'; @@ -29,12 +24,6 @@ function App() { const [customScopes, setCustomScopes] = useState(['eip155:1']); const [caipAccountIds, setCaipAccountIds] = useState([]); - // Track whether wagmi should be shown based on localStorage - const [wagmiIsActiveProvider, setWagmiIsActiveProvider] = useState(() => - isProviderActive('wagmi'), - ); - - // Track Wagmi connection errors const [wagmiError, setWagmiError] = useState(null); // Get Solana wallet error from provider context @@ -58,30 +47,18 @@ function App() { disconnect: legacyDisconnect, } = useLegacyEVMSDK(); const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount(); - const wagmiChainId = useChainId(); const { connectors, connectAsync: wagmiConnectAsync, status: wagmiStatus, } = useConnect(); - const { disconnect: wagmiDisconnect } = useDisconnect(); - // On mount, check if wagmi is connected but not marked as active provider - // If so, disconnect wagmi to clear stale state - useEffect(() => { - if (wagmiConnected && !isProviderActive('wagmi')) { - // Wagmi thinks it's connected but our localStorage says it shouldn't be - // Disconnect to clear stale state - wagmiDisconnect(); - } - }, []); const { connected: solanaConnected, publicKey: solanaPublicKey, wallets, select, connect: solanaConnect, - disconnect: solanaDisconnect, } = useWallet(); const handleCheckboxChange = useCallback( @@ -164,8 +141,6 @@ function App() { connector: metaMaskConnector, chainId, }); - setProviderActive('wagmi'); - setWagmiIsActiveProvider(true); } catch (err) { console.error('Wagmi connection error:', err); setWagmiError(err instanceof Error ? err : new Error(String(err))); @@ -191,35 +166,14 @@ function App() { }, [wallets, select, clearSolanaError]); const isConnected = status === 'connected'; + const isConnecting = status === 'connecting'; const isDisconnected = status === 'disconnected' || status === 'pending' || status === 'loaded'; const disconnect = useCallback(async () => { - clearAllActiveProviders(); - setWagmiIsActiveProvider(false); - - // Disconnect all connections if connected - if (isConnected) { - await sdkDisconnect(); - } - if (legacyConnected) { - await legacyDisconnect(); - } - if (wagmiConnected) { - wagmiDisconnect(); - } - if (solanaConnected) { - await solanaDisconnect(); - } + await sdkDisconnect(); }, [ sdkDisconnect, - legacyDisconnect, - wagmiDisconnect, - solanaDisconnect, - isConnected, - legacyConnected, - wagmiConnected, - solanaConnected, ]); const availableOptions = Object.keys(FEATURED_NETWORKS).reduce< @@ -231,7 +185,6 @@ function App() { return all; }, []); - const isConnecting = status === 'connecting'; return (
Connecting (Multichain) @@ -288,7 +241,7 @@ function App() { )} - {(!wagmiConnected || !wagmiIsActiveProvider) && ( + {(!wagmiConnected) && ( + > Reconnect (Multichain) )} {(isConnected || @@ -432,11 +377,12 @@ function App() { chainId={legacyChainId} accounts={legacyAccounts} sdk={legacySDK} + disconnect={legacyDisconnect} />
)} - {wagmiConnected && wagmiAddress && wagmiIsActiveProvider && ( + {wagmiConnected && wagmiAddress && (

Wagmi Connection diff --git a/playground/browser-playground/src/components/LegacyEVMCard.tsx b/playground/browser-playground/src/components/LegacyEVMCard.tsx index 7e0187a8..a3ceb6f7 100644 --- a/playground/browser-playground/src/components/LegacyEVMCard.tsx +++ b/playground/browser-playground/src/components/LegacyEVMCard.tsx @@ -8,6 +8,7 @@ interface LegacyEVMCardProps { chainId: string | undefined; accounts: string[]; sdk: any; + disconnect: () => Promise; } export function LegacyEVMCard({ @@ -15,6 +16,7 @@ export function LegacyEVMCard({ chainId, accounts, sdk, + disconnect, }: LegacyEVMCardProps) { const [response, setResponse] = useState(''); @@ -173,6 +175,13 @@ export function LegacyEVMCard({

Legacy EVM Connection

+
diff --git a/playground/browser-playground/src/components/SolanaWalletCard.tsx b/playground/browser-playground/src/components/SolanaWalletCard.tsx index 14243aca..b55d14c2 100644 --- a/playground/browser-playground/src/components/SolanaWalletCard.tsx +++ b/playground/browser-playground/src/components/SolanaWalletCard.tsx @@ -1,8 +1,5 @@ import { useConnection, useWallet } from '@solana/wallet-adapter-react'; -import { - WalletDisconnectButton, - WalletMultiButton, -} from '@solana/wallet-adapter-react-ui'; +import { WalletMultiButton } from '@solana/wallet-adapter-react-ui'; import { PublicKey, SystemProgram, Transaction, LAMPORTS_PER_SOL } from '@solana/web3.js'; import { useCallback, useState } from 'react'; @@ -12,7 +9,7 @@ import { useCallback, useState } from 'react'; */ export const SolanaWalletCard: React.FC = () => { const { connection } = useConnection(); - const { publicKey, connected, signMessage, signTransaction, sendTransaction } = + const { publicKey, connected, disconnect, signMessage, signTransaction, sendTransaction } = useWallet(); const [message, setMessage] = useState('Hello from MetaMask Connect!'); @@ -115,10 +112,21 @@ export const SolanaWalletCard: React.FC = () => { return (
-

- ☀️ - Solana Wallet -

+
+

+ ☀️ + Solana Wallet +

+ {connected && ( + + )} +
{/* Connection Status */} @@ -140,13 +148,11 @@ export const SolanaWalletCard: React.FC = () => { )} {/* Wallet Buttons */} -
- {!connected ? ( + {!connected && ( +
- ) : ( - - )} -
+
+ )} {/* Sign Message Section */} {connected && ( diff --git a/playground/browser-playground/src/components/WagmiCard.tsx b/playground/browser-playground/src/components/WagmiCard.tsx index ab9ebacd..91148529 100644 --- a/playground/browser-playground/src/components/WagmiCard.tsx +++ b/playground/browser-playground/src/components/WagmiCard.tsx @@ -7,6 +7,7 @@ import { useBlockNumber, useChainId, useConnectorClient, + useDisconnect, useSendTransaction, useSignMessage, useSwitchChain, @@ -17,6 +18,7 @@ import { TEST_IDS } from '@metamask/playground-ui'; export function WagmiCard() { const account = useAccount(); const chainId = useChainId(); + const { disconnect } = useDisconnect(); const { chains, switchChain } = useSwitchChain(); const { data: balance } = useBalance({ address: account.address }); const { data: blockNumber } = useBlockNumber({ watch: true }); @@ -60,6 +62,13 @@ export function WagmiCard() {

Wagmi Connection

+
diff --git a/playground/browser-playground/src/sdk/LegacyEVMSDKProvider.tsx b/playground/browser-playground/src/sdk/LegacyEVMSDKProvider.tsx index 45dad7d3..2ad2e385 100644 --- a/playground/browser-playground/src/sdk/LegacyEVMSDKProvider.tsx +++ b/playground/browser-playground/src/sdk/LegacyEVMSDKProvider.tsx @@ -12,12 +12,6 @@ import { useState, } from 'react'; -import { - isProviderActive, - setProviderActive, - removeProviderActive, -} from '../utils/activeProviderStorage'; - /** * Converts CAIP-2 keyed RPC URLs map to hex-keyed format. * Example: { 'eip155:1': 'url' } -> { '0x1': 'url' } @@ -95,7 +89,9 @@ export const LegacyEVMSDKProvider = ({ const providerInstance = await clientSDK.getProvider(); if (providerInstance) { - providerInstance.on('connect', () => { + providerInstance.on('connect', ({ chainId, accounts }: { chainId: string; accounts: string[] }) => { + setChainId(chainId); + setAccounts(accounts); setConnected(true); }); @@ -116,20 +112,11 @@ export const LegacyEVMSDKProvider = ({ setSDK(clientSDK); setProvider(providerInstance); - // Check if legacy-evm was previously active and restore connection state - // This handles page refresh scenarios where the SDK may have restored - // the session but didn't emit a connect event - if (isProviderActive('legacy-evm')) { - // Check if the SDK actually has a valid session - if (clientSDK.accounts.length > 0) { - setConnected(true); - setAccounts(clientSDK.accounts); - if (clientSDK.selectedChainId) { - setChainId(clientSDK.selectedChainId); - } - } else { - // SDK doesn't have accounts, clear stale localStorage state - removeProviderActive('legacy-evm'); + if (clientSDK.accounts.length > 0) { + setConnected(true); + setAccounts(clientSDK.accounts); + if (clientSDK.selectedChainId) { + setChainId(clientSDK.selectedChainId); } } } @@ -151,7 +138,6 @@ export const LegacyEVMSDKProvider = ({ // Ensure at least one chain ID is provided, default to mainnet if empty const chainIdsToUse = chainIds.length > 0 ? chainIds : ['0x1' as Hex]; await sdkInstance.connect({ chainIds: chainIdsToUse }); - setProviderActive('legacy-evm'); } catch (err) { console.error('Failed to connect:', err); setError(err as Error); @@ -168,7 +154,6 @@ export const LegacyEVMSDKProvider = ({ setConnected(false); setAccounts([]); setChainId(undefined); - removeProviderActive('legacy-evm'); } catch (error) { console.error('Failed to disconnect:', error); } diff --git a/playground/browser-playground/src/sdk/SDKProvider.tsx b/playground/browser-playground/src/sdk/SDKProvider.tsx index 2315e60d..f702d27f 100644 --- a/playground/browser-playground/src/sdk/SDKProvider.tsx +++ b/playground/browser-playground/src/sdk/SDKProvider.tsx @@ -21,12 +21,6 @@ import { useState, } from 'react'; -import { - isProviderActive, - setProviderActive, - removeProviderActive, -} from '../utils/activeProviderStorage'; - const SDKContext = createContext< | { session: SessionData | undefined; @@ -43,7 +37,7 @@ const SDKContext = createContext< >(undefined); export const SDKProvider = ({ children }: { children: React.ReactNode }) => { - const [status, setStatus] = useState('pending'); + const [status, setStatus] = useState('connecting'); const [session, setSession] = useState(undefined); const [error, setError] = useState(null); @@ -61,25 +55,19 @@ export const SDKProvider = ({ children }: { children: React.ReactNode }) => { }, 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' - ) { - // Only restore session if 'multichain' is marked as active in localStorage - // This prevents showing multichain cards when the session was created - // by legacy-evm or wagmi connections - if (isProviderActive('multichain')) { - setSession(payload.params as SessionData); - } - } else if (payload.method === 'stateChanged') { - setStatus(payload.params as ConnectionStatus); - } - }, }, }); + + // TODO: Check if we can get rid of transport.onNotification constructor param + sdkRef.current.then((sdkInstance) => { + setStatus(sdkInstance.status); + sdkInstance.on('stateChanged', (status: unknown) => { + setStatus(status as ConnectionStatus); + }); + sdkInstance.on('wallet_sessionChanged', (session: unknown) => { + setSession(session as SessionData); + }); + }); } }, []); @@ -90,7 +78,6 @@ export const SDKProvider = ({ children }: { children: React.ReactNode }) => { } const sdkInstance = await sdkRef.current; setSession(undefined); - removeProviderActive('multichain'); return sdkInstance.disconnect(); } catch (error) { setError(error as Error); @@ -106,11 +93,9 @@ export const SDKProvider = ({ children }: { children: React.ReactNode }) => { const sdkInstance = await sdkRef.current; // Track this provider as active BEFORE connecting // This ensures the onNotification handler will accept the session - setProviderActive('multichain'); await sdkInstance.connect(scopes, caipAccountIds); } catch (error) { // If connection fails, remove the active provider tracking - removeProviderActive('multichain'); setError(error as Error); } }, diff --git a/playground/browser-playground/src/utils/activeProviderStorage.ts b/playground/browser-playground/src/utils/activeProviderStorage.ts deleted file mode 100644 index 51c45431..00000000 --- a/playground/browser-playground/src/utils/activeProviderStorage.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint-disable no-restricted-globals -- localStorage is intentionally used for browser storage */ -/** - * Utility for managing active provider state in localStorage. - * This tracks which connection type(s) are currently active to ensure - * proper state restoration after page refresh. - */ - -const STORAGE_KEY = 'browser-playground.active-provider'; - -export type ProviderType = 'multichain' | 'legacy-evm' | 'wagmi'; - -/** - * Gets the currently active providers from localStorage. - * - * @returns Array of active provider types, or empty array if none - */ -export function getActiveProviders(): ProviderType[] { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) { - return []; - } - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - return parsed as ProviderType[]; - } - return []; - } catch (error) { - console.error( - '[activeProviderStorage] Failed to get active providers:', - error, - ); - return []; - } -} - -/** - * Checks if a specific provider is marked as active. - * - * @param provider - The provider type to check - * @returns true if the provider is active - */ -export function isProviderActive(provider: ProviderType): boolean { - return getActiveProviders().includes(provider); -} - -/** - * Sets a provider as active. Handles mutual exclusivity between - * legacy-evm and wagmi (since they share the same underlying provider). - * - * @param provider - The provider type to set as active - */ -export function setProviderActive(provider: ProviderType): void { - try { - const current = getActiveProviders(); - - // Handle mutual exclusivity between legacy-evm and wagmi - // They share the same underlying EVM provider, so only one can be active - let updated: ProviderType[]; - if (provider === 'legacy-evm') { - updated = current.filter( - (providerType) => - providerType !== 'wagmi' && providerType !== 'legacy-evm', - ); - updated.push('legacy-evm'); - } else if (provider === 'wagmi') { - updated = current.filter( - (providerType) => - providerType !== 'legacy-evm' && providerType !== 'wagmi', - ); - updated.push('wagmi'); - } else if (current.includes(provider)) { - // multichain already in list, no change needed - updated = current; - } else { - // multichain can coexist with either - updated = [...current, provider]; - } - - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); - } catch (error) { - console.error( - `[activeProviderStorage] Failed to set provider "${provider}" as active:`, - error, - ); - } -} - -/** - * Removes a specific provider from the active list. - * - * @param provider - The provider type to remove - */ -export function removeProviderActive(provider: ProviderType): void { - try { - const current = getActiveProviders(); - const updated = current.filter((providerType) => providerType !== provider); - if (updated.length === 0) { - localStorage.removeItem(STORAGE_KEY); - } else { - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); - } - } catch (error) { - console.error( - `[activeProviderStorage] Failed to remove provider "${provider}" from active list:`, - error, - ); - } -} - -/** - * Clears all active provider state from localStorage. - * Called when disconnecting all connections. - */ -export function clearAllActiveProviders(): void { - try { - localStorage.removeItem(STORAGE_KEY); - } catch (error) { - console.error( - '[activeProviderStorage] Failed to clear all active providers:', - error, - ); - } -}