From e0b5721dd712bdb5a51b4bab97f6615ea852a0ff Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 5 Feb 2026 12:03:33 +0100 Subject: [PATCH 1/4] feat: implement SDK singleton pattern for core sharing - Add singleton caching using globalThis.__metamaskCore - Multiple SDK clients (Multichain, EVM, Solana) now share a single core instance - Add getCachedCore(), hasCachedCore(), and _clearCoreForTesting() utilities - Add Experiment7 for verifying core sharing behavior - Add singleton.test.ts with comprehensive test coverage This ensures that regardless of how many SDK clients are instantiated, they all share the same underlying MultichainCore instance, preventing state conflicts and resource duplication. Co-authored-by: Cursor --- .../connect-multichain/src/index.browser.ts | 56 ++- .../connect-multichain/src/index.native.ts | 56 ++- packages/connect-multichain/src/index.node.ts | 56 ++- .../connect-multichain/src/singleton.test.ts | 415 ++++++++++++++++++ packages/connect-multichain/tests/setup.ts | 7 + .../src/experiments/Experiment7.tsx | 194 ++++++++ .../src/experiments/ExperimentsApp.tsx | 5 +- .../experiments/shared/ExperimentsLayout.tsx | 8 +- 8 files changed, 756 insertions(+), 41 deletions(-) create mode 100644 packages/connect-multichain/src/singleton.test.ts create mode 100644 playground/browser-playground/src/experiments/Experiment7.tsx diff --git a/packages/connect-multichain/src/index.browser.ts b/packages/connect-multichain/src/index.browser.ts index 4cfcdf88..cacc03af 100644 --- a/packages/connect-multichain/src/index.browser.ts +++ b/packages/connect-multichain/src/index.browser.ts @@ -2,32 +2,57 @@ // Buffer polyfill must be imported first to set up globalThis.Buffer import './polyfills/buffer-shim'; -import type { CreateMultichainFN } from './domain'; +import type { CreateMultichainFN, MultichainCore } from './domain'; import { enableDebug } from './domain'; import { MetaMaskConnectMultichain } from './multichain'; -import { - createIsolatedStorage, - generateInstanceId, -} from './store/create-storage'; +import { createIsolatedStorage } from './store/create-storage'; import { ModalFactory } from './ui'; export * from './domain'; +// Singleton key for the core instance (using globalThis for cross-environment support) +const CORE_KEY = '__metamaskCore'; + +/** + * Get the cached core instance (if available) + */ +export function getCachedCore(): MultichainCore | undefined { + return (globalThis as Record)[CORE_KEY] as + | MultichainCore + | undefined; +} + +/** + * Check if a core instance is cached + */ +export function hasCachedCore(): boolean { + return CORE_KEY in globalThis; +} + +/** + * Clear the cached core (for testing) + * @internal + */ +export function _clearCoreForTesting(): void { + delete (globalThis as Record)[CORE_KEY]; +} + export const createMultichainClient: CreateMultichainFN = async (options) => { if (options.debug) { enableDebug('metamask-sdk:*'); } - const uiModules = await import('./ui/modals/web'); + // Return existing singleton if available + const existingCore = getCachedCore(); + if (existingCore) { + return existingCore; + } - // Generate deterministic instanceId if not provided - // Empty string means no prefixing (for backwards compatibility / testing) - const sdkType = options.sdkType ?? 'multichain'; - const instanceId = - options.instanceId ?? generateInstanceId(options.dapp.name, sdkType); + // Create new core + const uiModules = await import('./ui/modals/web'); const storage = await createIsolatedStorage({ - instanceId, + instanceId: options.instanceId ?? '', userStorage: options.storage, createAdapter: async () => { const { StoreAdapterWeb } = await import('./store/adapters/web'); @@ -36,7 +61,7 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { }); const factory = new ModalFactory(uiModules); - return MetaMaskConnectMultichain.create({ + const core = await MetaMaskConnectMultichain.create({ ...options, storage, ui: { @@ -44,4 +69,9 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { factory, }, }); + + // Cache the singleton + (globalThis as Record)[CORE_KEY] = core; + + return core; }; diff --git a/packages/connect-multichain/src/index.native.ts b/packages/connect-multichain/src/index.native.ts index ed81af2c..be3b2be2 100644 --- a/packages/connect-multichain/src/index.native.ts +++ b/packages/connect-multichain/src/index.native.ts @@ -2,32 +2,57 @@ // Buffer polyfill must be imported first to set up global.Buffer import './polyfills/buffer-shim'; -import type { CreateMultichainFN } from './domain'; +import type { CreateMultichainFN, MultichainCore } from './domain'; import { enableDebug } from './domain'; import { MetaMaskConnectMultichain } from './multichain'; -import { - createIsolatedStorage, - generateInstanceId, -} from './store/create-storage'; +import { createIsolatedStorage } from './store/create-storage'; import { ModalFactory } from './ui/index.native'; export * from './domain'; +// Singleton key for the core instance (using globalThis for cross-environment support) +const CORE_KEY = '__metamaskCore'; + +/** + * Get the cached core instance (if available) + */ +export function getCachedCore(): MultichainCore | undefined { + return (globalThis as Record)[CORE_KEY] as + | MultichainCore + | undefined; +} + +/** + * Check if a core instance is cached + */ +export function hasCachedCore(): boolean { + return CORE_KEY in globalThis; +} + +/** + * Clear the cached core (for testing) + * @internal + */ +export function _clearCoreForTesting(): void { + delete (globalThis as Record)[CORE_KEY]; +} + export const createMultichainClient: CreateMultichainFN = async (options) => { if (options.debug) { enableDebug('metamask-sdk:*'); } - const uiModules = await import('./ui/modals/rn'); + // Return existing singleton if available + const existingCore = getCachedCore(); + if (existingCore) { + return existingCore; + } - // Generate deterministic instanceId if not provided - // Empty string means no prefixing (for backwards compatibility / testing) - const sdkType = options.sdkType ?? 'multichain'; - const instanceId = - options.instanceId ?? generateInstanceId(options.dapp.name, sdkType); + // Create new core + const uiModules = await import('./ui/modals/rn'); const storage = await createIsolatedStorage({ - instanceId, + instanceId: options.instanceId ?? '', userStorage: options.storage, createAdapter: async () => { const { StoreAdapterRN } = await import('./store/adapters/rn'); @@ -36,7 +61,7 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { }); const factory = new ModalFactory(uiModules); - return MetaMaskConnectMultichain.create({ + const core = await MetaMaskConnectMultichain.create({ ...options, storage, ui: { @@ -44,4 +69,9 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { factory, }, }); + + // Cache the singleton + (globalThis as Record)[CORE_KEY] = core; + + return core; }; diff --git a/packages/connect-multichain/src/index.node.ts b/packages/connect-multichain/src/index.node.ts index c8bac3aa..603cb43a 100644 --- a/packages/connect-multichain/src/index.node.ts +++ b/packages/connect-multichain/src/index.node.ts @@ -1,29 +1,54 @@ -import type { CreateMultichainFN } from './domain'; +import type { CreateMultichainFN, MultichainCore } from './domain'; import { enableDebug } from './domain'; import { MetaMaskConnectMultichain } from './multichain'; -import { - createIsolatedStorage, - generateInstanceId, -} from './store/create-storage'; +import { createIsolatedStorage } from './store/create-storage'; import { ModalFactory } from './ui'; export * from './domain'; +// Singleton key for the core instance (using globalThis for cross-environment support) +const CORE_KEY = '__metamaskCore'; + +/** + * Get the cached core instance (if available) + */ +export function getCachedCore(): MultichainCore | undefined { + return (globalThis as Record)[CORE_KEY] as + | MultichainCore + | undefined; +} + +/** + * Check if a core instance is cached + */ +export function hasCachedCore(): boolean { + return CORE_KEY in globalThis; +} + +/** + * Clear the cached core (for testing) + * @internal + */ +export function _clearCoreForTesting(): void { + delete (globalThis as Record)[CORE_KEY]; +} + export const createMultichainClient: CreateMultichainFN = async (options) => { if (options.debug) { enableDebug('metamask-sdk:*'); } - const uiModules = await import('./ui/modals/node'); + // Return existing singleton if available + const existingCore = getCachedCore(); + if (existingCore) { + return existingCore; + } - // Generate deterministic instanceId if not provided - // Empty string means no prefixing (for backwards compatibility / testing) - const sdkType = options.sdkType ?? 'multichain'; - const instanceId = - options.instanceId ?? generateInstanceId(options.dapp.name, sdkType); + // Create new core + const uiModules = await import('./ui/modals/node'); const storage = await createIsolatedStorage({ - instanceId, + instanceId: options.instanceId ?? '', userStorage: options.storage, createAdapter: async () => { const { StoreAdapterNode } = await import('./store/adapters/node'); @@ -32,7 +57,7 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { }); const factory = new ModalFactory(uiModules); - return MetaMaskConnectMultichain.create({ + const core = await MetaMaskConnectMultichain.create({ ...options, storage, ui: { @@ -40,4 +65,9 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { factory, }, }); + + // Cache the singleton + (globalThis as Record)[CORE_KEY] = core; + + return core; }; diff --git a/packages/connect-multichain/src/singleton.test.ts b/packages/connect-multichain/src/singleton.test.ts new file mode 100644 index 00000000..fdf48dbe --- /dev/null +++ b/packages/connect-multichain/src/singleton.test.ts @@ -0,0 +1,415 @@ +/** + * Singleton Approach Verification Tests + * + * These tests verify the assumptions needed for the singleton approach to work. + * They document current behavior and what would need to change. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from './domain/events'; +import type { SDKEvents } from './domain/events/types'; +import type { StoreAdapter, StoreClient } from './domain'; +import { PrefixedStoreAdapter } from './store/adapters/prefixed'; +import { generateInstanceId, createIsolatedStorage } from './store/create-storage'; + +// Mock Core for testing event broadcasting +class MockMultichainCore extends EventEmitter { + public _status: 'pending' | 'connected' | 'disconnected' = 'pending'; + + get status() { + return this._status; + } + + set status(value: 'pending' | 'connected' | 'disconnected') { + this._status = value; + } + + async connect() { + this._status = 'connected'; + this.emit('wallet_sessionChanged', { sessionScopes: { 'eip155:1': {} } }); + } + + async disconnect() { + this._status = 'disconnected'; + this.emit('wallet_sessionChanged', undefined); + } +} + +describe('Singleton Approach Verification', () => { + describe('Assumption 1: Core can be shared', () => { + it('currently each SDK type creates its own core (problematic)', () => { + // This documents the CURRENT behavior + // Each call to createMultichainClient() creates a new instance + const instanceId1 = generateInstanceId('MyApp', 'multichain'); + const instanceId2 = generateInstanceId('MyApp', 'evm'); + const instanceId3 = generateInstanceId('MyApp', 'solana'); + + expect(instanceId1).toBe('myapp-multichain'); + expect(instanceId2).toBe('myapp-evm'); + expect(instanceId3).toBe('myapp-solana'); + + // These are different, which means separate storage namespaces + expect(instanceId1).not.toBe(instanceId2); + expect(instanceId2).not.toBe(instanceId3); + }); + + it('singleton would use same instanceId for all SDK types', () => { + // For singleton, we would NOT use SDK type in the instance ID + const sharedInstanceId = 'myapp'; // Just the dapp name + + // All SDK types would share this + const evmPrefix = `${sharedInstanceId}:`; + const solanaPrefix = `${sharedInstanceId}:`; + + expect(evmPrefix).toBe(solanaPrefix); + }); + }); + + describe('Assumption 2: Clients are thin wrappers', () => { + it('EVM client state can be rebuilt from core events', () => { + const core = new MockMultichainCore(); + let sessionScopes: Record = {}; + + // Simulating what EVM client does + core.on('wallet_sessionChanged', (session) => { + sessionScopes = (session as any)?.sessionScopes ?? {}; + }); + + // Before connect + expect(sessionScopes).toEqual({}); + + // After connect - state is rebuilt from event + core.connect(); + expect(sessionScopes).toEqual({ 'eip155:1': {} }); + + // After disconnect - state is rebuilt from event + core.disconnect(); + expect(sessionScopes).toEqual({}); + }); + }); + + describe('Assumption 3: Events broadcast to all clients', () => { + it('multiple listeners receive the same event when core is shared', () => { + const sharedCore = new MockMultichainCore(); + const receivedByClient1: unknown[] = []; + const receivedByClient2: unknown[] = []; + + // Two "clients" listening to same core + sharedCore.on('wallet_sessionChanged', (session) => { + receivedByClient1.push(session); + }); + + sharedCore.on('wallet_sessionChanged', (session) => { + receivedByClient2.push(session); + }); + + // Emit once + sharedCore.connect(); + + // Both received it + expect(receivedByClient1.length).toBe(1); + expect(receivedByClient2.length).toBe(1); + expect(receivedByClient1[0]).toEqual(receivedByClient2[0]); + }); + }); + + describe('Assumption 5: Storage is shared', () => { + it('currently SDK types have isolated storage', async () => { + const storage = new Map(); + const mockAdapter: StoreAdapter = { + platform: 'web', + get: vi.fn((key: string) => Promise.resolve(storage.get(key) ?? null)), + set: vi.fn((key: string, value: string) => { + storage.set(key, value); + return Promise.resolve(); + }), + delete: vi.fn((key: string) => { + storage.delete(key); + return Promise.resolve(); + }), + } as unknown as StoreAdapter; + + // Simulating current behavior: each SDK type gets different prefix + const evmAdapter = new PrefixedStoreAdapter(mockAdapter, 'myapp-evm:'); + const solanaAdapter = new PrefixedStoreAdapter(mockAdapter, 'myapp-solana:'); + + // Each writes to "transport" + await evmAdapter.set('transport', 'browser'); + await solanaAdapter.set('transport', 'mwp'); + + // They see different values (isolated) + expect(await evmAdapter.get('transport')).toBe('browser'); + expect(await solanaAdapter.get('transport')).toBe('mwp'); + + // Underlying storage has both + expect(storage.get('myapp-evm:transport')).toBe('browser'); + expect(storage.get('myapp-solana:transport')).toBe('mwp'); + }); + + it('singleton would use shared storage', async () => { + const storage = new Map(); + const mockAdapter: StoreAdapter = { + platform: 'web', + get: vi.fn((key: string) => Promise.resolve(storage.get(key) ?? null)), + set: vi.fn((key: string, value: string) => { + storage.set(key, value); + return Promise.resolve(); + }), + delete: vi.fn((key: string) => { + storage.delete(key); + return Promise.resolve(); + }), + } as unknown as StoreAdapter; + + // Singleton: same prefix for all SDK types + const sharedPrefix = 'myapp:'; + const evmAdapter = new PrefixedStoreAdapter(mockAdapter, sharedPrefix); + const solanaAdapter = new PrefixedStoreAdapter(mockAdapter, sharedPrefix); + + // First write wins, second overwrites (shared) + await evmAdapter.set('transport', 'browser'); + await solanaAdapter.set('transport', 'mwp'); + + // Both see the same value + expect(await evmAdapter.get('transport')).toBe('mwp'); + expect(await solanaAdapter.get('transport')).toBe('mwp'); + + // Only one key in storage + expect(storage.size).toBe(1); + expect(storage.get('myapp:transport')).toBe('mwp'); + }); + }); + + describe('Assumption 6: Disconnect coordination', () => { + it('currently disconnect terminates for everyone', async () => { + const sharedCore = new MockMultichainCore(); + let evmSessionActive = false; + let solanaSessionActive = false; + + // EVM client listens + sharedCore.on('wallet_sessionChanged', (session) => { + evmSessionActive = session !== undefined; + }); + + // Solana client listens + sharedCore.on('wallet_sessionChanged', (session) => { + solanaSessionActive = session !== undefined; + }); + + // Both connect + await sharedCore.connect(); + expect(evmSessionActive).toBe(true); + expect(solanaSessionActive).toBe(true); + + // One disconnects - BOTH lose session (current problematic behavior) + await sharedCore.disconnect(); + expect(evmSessionActive).toBe(false); + expect(solanaSessionActive).toBe(false); + }); + + it('reference counting would allow partial disconnect', async () => { + // This is the PROPOSED behavior with reference counting + const clients = new Set(); + let sessionActive = false; + + const registerClient = (id: string) => clients.add(id); + const unregisterClient = (id: string) => { + clients.delete(id); + return clients.size === 0; + }; + + // Both register + registerClient('evm'); + registerClient('solana'); + sessionActive = true; + + // EVM unregisters + const shouldTerminate1 = unregisterClient('evm'); + expect(shouldTerminate1).toBe(false); + // Session should remain active + expect(sessionActive).toBe(true); + + // Solana unregisters (last client) + const shouldTerminate2 = unregisterClient('solana'); + expect(shouldTerminate2).toBe(true); + // Now we can terminate + sessionActive = false; + expect(sessionActive).toBe(false); + }); + }); + + describe('Core Registry Pattern', () => { + it('getOrCreateCore returns same instance for same dappId', () => { + // Simulating the proposed core registry + const coreRegistry = new Map(); + + const getOrCreateCore = (dappId: string): MockMultichainCore => { + const existing = coreRegistry.get(dappId); + if (existing) { + return existing; + } + const newCore = new MockMultichainCore(); + coreRegistry.set(dappId, newCore); + return newCore; + }; + + const core1 = getOrCreateCore('myapp'); + const core2 = getOrCreateCore('myapp'); + const core3 = getOrCreateCore('otherapp'); + + // Same dappId = same instance + expect(core1).toBe(core2); + + // Different dappId = different instance + expect(core1).not.toBe(core3); + }); + + it('different dapps are still isolated', () => { + const coreRegistry = new Map(); + + const getOrCreateCore = (dappId: string): MockMultichainCore => { + const existing = coreRegistry.get(dappId); + if (existing) { + return existing; + } + const newCore = new MockMultichainCore(); + coreRegistry.set(dappId, newCore); + return newCore; + }; + + const dappA = getOrCreateCore('dapp-a'); + const dappB = getOrCreateCore('dapp-b'); + + dappA.connect(); + + // Different apps have different cores, so different states + expect(dappA.status).toBe('connected'); + expect(dappB.status).toBe('pending'); + }); + }); + + describe('Scope Merging (Assumption 7)', () => { + it('merging scopes should combine existing and new', () => { + const existingScopes: string[] = ['eip155:1', 'eip155:137']; + const requestedScopes: string[] = ['eip155:1', 'solana:mainnet']; + + // Current behavior: check if same + const isSame = + existingScopes.length === requestedScopes.length && + existingScopes.every((s) => requestedScopes.includes(s)); + expect(isSame).toBe(false); + + // Proposed: merge instead of replace + const mergedScopes = [...new Set([...existingScopes, ...requestedScopes])]; + expect(mergedScopes).toEqual([ + 'eip155:1', + 'eip155:137', + 'solana:mainnet', + ]); + }); + + it('should detect when no new scopes are needed', () => { + const existingScopes = ['eip155:1', 'eip155:137', 'solana:mainnet']; + const requestedScopes = ['eip155:1']; // Subset of existing + + const newScopes = requestedScopes.filter( + (s) => !existingScopes.includes(s), + ); + + expect(newScopes).toEqual([]); + // No scope update needed + }); + + it('should detect when new scopes need to be added', () => { + const existingScopes = ['eip155:1']; + const requestedScopes = ['eip155:1', 'solana:mainnet']; + + const newScopes = requestedScopes.filter( + (s) => !existingScopes.includes(s), + ); + + expect(newScopes).toEqual(['solana:mainnet']); + // Need to add solana:mainnet to session + }); + }); +}); + +describe('Edge Cases', () => { + describe('Page refresh behavior', () => { + it('shared storage persists across "refreshes"', async () => { + const persistedStorage = new Map(); + + // First "page load" + { + const mockAdapter: StoreAdapter = { + platform: 'web', + get: vi.fn((key) => Promise.resolve(persistedStorage.get(key) ?? null)), + set: vi.fn((key, value) => { + persistedStorage.set(key, value); + return Promise.resolve(); + }), + delete: vi.fn(), + } as unknown as StoreAdapter; + + const storage = new PrefixedStoreAdapter(mockAdapter, 'myapp:'); + await storage.set('session', JSON.stringify({ connected: true })); + } + + // "Page refresh" - new instances, same persisted storage + { + const mockAdapter: StoreAdapter = { + platform: 'web', + get: vi.fn((key) => Promise.resolve(persistedStorage.get(key) ?? null)), + set: vi.fn((key, value) => { + persistedStorage.set(key, value); + return Promise.resolve(); + }), + delete: vi.fn(), + } as unknown as StoreAdapter; + + const storage = new PrefixedStoreAdapter(mockAdapter, 'myapp:'); + const session = await storage.get('session'); + expect(JSON.parse(session!)).toEqual({ connected: true }); + } + }); + }); + + describe('Order of client creation', () => { + it('EVM first, then Solana should work', () => { + const clients = new Set(); + let sessionScopes: string[] = []; + + const connect = (clientId: string, scopes: string[]) => { + clients.add(clientId); + // Merge scopes + sessionScopes = [...new Set([...sessionScopes, ...scopes])]; + }; + + // EVM connects first + connect('evm', ['eip155:1']); + expect(sessionScopes).toEqual(['eip155:1']); + + // Solana connects second (adds to existing) + connect('solana', ['solana:mainnet']); + expect(sessionScopes).toEqual(['eip155:1', 'solana:mainnet']); + }); + + it('Solana first, then EVM should work', () => { + const clients = new Set(); + let sessionScopes: string[] = []; + + const connect = (clientId: string, scopes: string[]) => { + clients.add(clientId); + sessionScopes = [...new Set([...sessionScopes, ...scopes])]; + }; + + // Solana connects first + connect('solana', ['solana:mainnet']); + expect(sessionScopes).toEqual(['solana:mainnet']); + + // EVM connects second (adds to existing) + connect('evm', ['eip155:1']); + expect(sessionScopes).toEqual(['solana:mainnet', 'eip155:1']); + }); + }); +}); diff --git a/packages/connect-multichain/tests/setup.ts b/packages/connect-multichain/tests/setup.ts index 4c959fc2..cbedeff0 100644 --- a/packages/connect-multichain/tests/setup.ts +++ b/packages/connect-multichain/tests/setup.ts @@ -4,6 +4,13 @@ // Setup file to handle unhandled promise rejections in tests // This prevents CI failures from expected unhandled rejections in web-mobile timeout tests +import { beforeEach } from 'vitest'; + +// Clear any cached core before each test (uses globalThis which works in all environments) +beforeEach(() => { + delete (globalThis as Record).__metamaskCore; +}); + const originalUnhandledRejection = process.listeners('unhandledRejection'); process.removeAllListeners('unhandledRejection'); diff --git a/playground/browser-playground/src/experiments/Experiment7.tsx b/playground/browser-playground/src/experiments/Experiment7.tsx new file mode 100644 index 00000000..31ca11bd --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment7.tsx @@ -0,0 +1,194 @@ +import { useState, useCallback, useRef } from 'react'; + +import { + createMultichainClient, + hasCachedCore, + type MultichainCore, +} from '@metamask/connect-multichain'; +import { + createEVMClient, + type MetamaskConnectEVM, +} from '@metamask/connect-evm'; + +import { ConnectionCard, ActionButton, type ConnectionStatus } from './shared'; + +const DAPP_NAME = 'Experiment 7 - Core Sharing'; + +/** + * Experiment 7: Core Sharing Verification + * + * This experiment verifies that: + * 1. Multiple SDK clients (Multichain, EVM) share the same core instance + * 2. The singleton pattern works correctly + */ +export function Experiment7() { + const [multichainCore, setMultichainCore] = useState( + null, + ); + const [evmClient, setEvmClient] = useState(null); + const [createCount, setCreateCount] = useState(0); + const [hasCached, setHasCached] = useState(hasCachedCore()); + const [sameCore, setSameCore] = useState(null); + const [error, setError] = useState(null); + + // Track core references for comparison + const multichainCoreRef = useRef(null); + const evmCoreRef = useRef(null); + + const refreshState = useCallback(() => { + setHasCached(hasCachedCore()); + }, []); + + const createMultichain = useCallback(async () => { + try { + setError(null); + const client = await createMultichainClient({ + dapp: { + name: DAPP_NAME, + url: window.location.href, + }, + api: { + supportedNetworks: { + 'eip155:1': 'https://mainnet.infura.io/v3/YOUR_KEY', + }, + }, + }); + multichainCoreRef.current = client as MultichainCore; + setMultichainCore(client as MultichainCore); + setCreateCount((prev) => prev + 1); + refreshState(); + + // Check if same core as EVM + if (evmCoreRef.current) { + const isSame = multichainCoreRef.current === evmCoreRef.current; + setSameCore(isSame ? '✅ SAME CORE' : '❌ DIFFERENT CORES'); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [refreshState]); + + const createEVM = useCallback(async () => { + try { + setError(null); + const client = await createEVMClient({ + dapp: { + name: DAPP_NAME, + url: window.location.href, + }, + api: { + supportedNetworks: { + '0x1': 'https://mainnet.infura.io/v3/YOUR_KEY', + }, + }, + }); + // The EVM client wraps the core, but we can compare by checking window + // @ts-expect-error - accessing window for testing + evmCoreRef.current = window.__metamaskCore; + setEvmClient(client); + setCreateCount((prev) => prev + 1); + refreshState(); + + // Check if same core as Multichain + if (multichainCoreRef.current) { + const isSame = multichainCoreRef.current === evmCoreRef.current; + setSameCore(isSame ? '✅ SAME CORE' : '❌ DIFFERENT CORES'); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [refreshState]); + + return ( +
+

Experiment 7: Core Sharing

+

+ Verifies that multiple SDK clients share the same core instance + (singleton pattern). +

+ + {/* State Display */} +
+

Singleton State

+ + + + + + + + + + + + + + + +
Has Cached Core:{hasCached ? '✅ Yes' : '❌ No'}
Create Calls Made:{createCount}
Core Comparison: + {sameCore ?? '(Create both clients to compare)'} +
+
+ + Refresh State + +
+
+ + {/* Client Cards */} +
+ {/* Multichain Client */} + + + {multichainCore ? 'Created ✓' : 'Create Multichain Client'} + + + + {/* EVM Client */} + + + {evmClient ? 'Created ✓' : 'Create EVM Client'} + + +
+ + {/* Error Display */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Checklist */} +
+

+ Expected Behavior +

+
    +
  • ✓ Creating first client should cache the core
  • +
  • ✓ Creating second client should return the SAME core
  • +
  • ✓ "Core Comparison" should show "✅ SAME CORE"
  • +
  • ✓ Only 1 core exists, even with 2 create calls
  • +
+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/ExperimentsApp.tsx b/playground/browser-playground/src/experiments/ExperimentsApp.tsx index 32764bc6..2d7c2544 100644 --- a/playground/browser-playground/src/experiments/ExperimentsApp.tsx +++ b/playground/browser-playground/src/experiments/ExperimentsApp.tsx @@ -6,11 +6,12 @@ import { Experiment3 } from './Experiment3'; import { Experiment4 } from './Experiment4'; import { Experiment5 } from './Experiment5'; import { Experiment6 } from './Experiment6'; +import { Experiment7 } from './Experiment7'; // Get experiment from URL hash, default to exp1 function getExperimentFromHash(): ExperimentId { const hash = window.location.hash.slice(1); - if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6'].includes(hash)) { + if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7'].includes(hash)) { return hash as ExperimentId; } return 'exp1'; @@ -56,6 +57,8 @@ export function ExperimentsApp() { return ; case 'exp6': return ; + case 'exp7': + return ; default: return ; } diff --git a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx index 79671d4e..b291bb56 100644 --- a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx +++ b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx @@ -7,7 +7,8 @@ export type ExperimentId = | 'exp3' | 'exp4' | 'exp5' - | 'exp6'; + | 'exp6' + | 'exp7'; type ExperimentInfo = { id: ExperimentId; @@ -46,6 +47,11 @@ export const EXPERIMENTS: ExperimentInfo[] = [ title: 'Exp 6: All Three', description: 'Multichain + EVM + Wagmi together', }, + { + id: 'exp7', + title: 'Exp 7: Core Sharing', + description: 'Verify singleton core is shared across SDK types', + }, ]; type ExperimentsLayoutProps = { From 59fa1d8f660283f82bec5b77ceb7bff9031ee3c4 Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 5 Feb 2026 12:45:35 +0100 Subject: [PATCH 2/4] feat: implement client registration and disconnect coordination (Phase 2) - Add registerClient/unregisterClient/getClientCount to MultichainCore - EVM and Solana clients now register on connect, unregister on disconnect - Disconnect only revokes session when the last client disconnects - Add Experiment 8 to verify disconnect coordination behavior - Fix experiment imports to use correct package names This enables multiple SDK clients to share a session without tripping over each other during disconnect operations. Co-authored-by: Cursor --- packages/connect-evm/src/connect.test.ts | 13 + packages/connect-evm/src/connect.ts | 31 +- .../src/domain/multichain/index.ts | 38 ++ .../src/multichain/index.ts | 48 ++- packages/connect-solana/src/connect.test.ts | 32 +- packages/connect-solana/src/connect.ts | 27 +- .../src/experiments/Experiment1.tsx | 4 +- .../src/experiments/Experiment2.tsx | 4 +- .../src/experiments/Experiment3.tsx | 8 +- .../src/experiments/Experiment8.tsx | 325 ++++++++++++++++++ .../src/experiments/ExperimentsApp.tsx | 5 +- .../experiments/shared/ExperimentsLayout.tsx | 8 +- 12 files changed, 527 insertions(+), 16 deletions(-) create mode 100644 playground/browser-playground/src/experiments/Experiment8.tsx diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index 7e0c7e90..8942233f 100644 --- a/packages/connect-evm/src/connect.test.ts +++ b/packages/connect-evm/src/connect.test.ts @@ -28,6 +28,9 @@ function createMockCore() { }, }; + // Track registered clients for testing + const registeredClients = new Map(); + const mockCore: Partial = { // Delegate event methods to the real emitter on: vi.fn((event: string, handler: (...args: any[]) => void) => { @@ -63,6 +66,16 @@ function createMockCore() { disconnect: vi.fn().mockResolvedValue(undefined), + // Client registration methods (for singleton pattern) + registerClient: vi.fn((clientId: string, sdkType: string) => { + registeredClients.set(clientId, { clientId, sdkType }); + }), + unregisterClient: vi.fn((clientId: string) => { + registeredClients.delete(clientId); + return registeredClients.size === 0; + }), + getClientCount: vi.fn(() => registeredClients.size), + transport: mockTransport as any, storage: mockStorage as any, status: 'connected' as const, diff --git a/packages/connect-evm/src/connect.ts b/packages/connect-evm/src/connect.ts index 098e4988..8f3af6cf 100644 --- a/packages/connect-evm/src/connect.ts +++ b/packages/connect-evm/src/connect.ts @@ -102,6 +102,12 @@ export class MetamaskConnectEVM { /** The clean-up function for the notification handler */ #removeNotificationHandler?: () => void; + /** Unique identifier for this client instance */ + readonly #clientId: string; + + /** Whether this client is currently registered with the core */ + #isRegistered = false; + /** * Creates a new MetamaskConnectEVM instance. * Use the static `create()` method instead to ensure proper async initialization. @@ -112,6 +118,7 @@ export class MetamaskConnectEVM { */ private constructor({ core, eventHandlers }: MetamaskConnectEVMOptions) { this.#core = core; + this.#clientId = `evm-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; this.#provider = new EIP1193Provider( core, @@ -341,6 +348,12 @@ export class MetamaskConnectEVM { forceRequest, ); + // Register this client with the core (for reference counting) + if (!this.#isRegistered) { + this.#core.registerClient(this.#clientId, 'evm'); + this.#isRegistered = true; + } + const hexPermittedChainIds = getPermittedEthChainIds(this.#sessionScopes); const initialAccounts = await this.#core.transport.sendEip1193Message< @@ -479,13 +492,29 @@ export class MetamaskConnectEVM { /** * Disconnects from the wallet by revoking the session and cleaning up event listeners. + * Only actually revokes the session if this is the last client using the shared core. * * @returns A promise that resolves when disconnection is complete */ async disconnect(): Promise { logger('request: disconnect'); - await this.#core.disconnect(); + // Unregister this client from the core + const isLastClient = this.#isRegistered + ? this.#core.unregisterClient(this.#clientId) + : true; + this.#isRegistered = false; + + // Only actually disconnect if this was the last client + if (isLastClient) { + logger('Last client disconnecting, revoking session'); + await this.#core.disconnect(); + } else { + logger( + `Other clients remain (${this.#core.getClientCount()}), skipping session revocation`, + ); + } + this.#onDisconnect(); this.#clearConnectionState(); diff --git a/packages/connect-multichain/src/domain/multichain/index.ts b/packages/connect-multichain/src/domain/multichain/index.ts index 0aa16dd8..24baee5d 100644 --- a/packages/connect-multichain/src/domain/multichain/index.ts +++ b/packages/connect-multichain/src/domain/multichain/index.ts @@ -24,6 +24,18 @@ export enum TransportType { UNKNOWN = 'unknown', } +/** + * Information about a registered client. + */ +export type ClientInfo = { + /** Unique identifier for the client */ + clientId: string; + /** The SDK type (e.g., 'evm', 'solana', 'multichain') */ + sdkType: string; + /** When the client was registered */ + registeredAt: number; +}; + /** * Abstract base class for the Multichain SDK implementation. * @@ -70,6 +82,32 @@ export abstract class MultichainCore extends EventEmitter { abstract openDeeplinkIfNeeded(): void; + /** + * Registers a client with the core. + * Call this when a thin client (EVM, Solana) connects. + * + * @param clientId - Unique identifier for the client + * @param sdkType - The SDK type (e.g., 'evm', 'solana') + */ + abstract registerClient(clientId: string, sdkType: string): void; + + /** + * Unregisters a client from the core. + * Call this when a thin client disconnects. + * Returns true if this was the last client (actual disconnect should happen). + * + * @param clientId - The client ID to unregister + * @returns True if this was the last client, false if others remain + */ + abstract unregisterClient(clientId: string): boolean; + + /** + * Gets the number of currently registered clients. + * + * @returns The number of active clients + */ + abstract getClientCount(): number; + constructor(protected readonly options: MultichainOptions) { super(); } diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index 83fa122a..b08ef155 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -44,10 +44,11 @@ import { isEnabled as isLoggerEnabled, } from '../domain/logger'; import { + type ClientInfo, type ConnectionRequest, + type ConnectionStatus, type ExtendedTransport, MultichainCore, - type ConnectionStatus, } from '../domain/multichain'; import { getPlatformType, @@ -83,6 +84,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { #listener: (() => void | Promise) | undefined; + /** Tracks active clients using this core instance */ + readonly #activeClients: Map = new Map(); + get status(): ConnectionStatus { return this._status; } @@ -841,6 +845,48 @@ export class MetaMaskConnectMultichain extends MultichainCore { return requestRouter.invokeMethod(request); } + /** + * Registers a client with the core. + * Call this when a thin client (EVM, Solana) connects. + * + * @param clientId - Unique identifier for the client + * @param sdkType - The SDK type (e.g., 'evm', 'solana') + */ + registerClient(clientId: string, sdkType: string): void { + logger(`Registering client: ${clientId} (${sdkType})`); + this.#activeClients.set(clientId, { + clientId, + sdkType, + registeredAt: Date.now(), + }); + logger(`Active clients: ${this.#activeClients.size}`); + } + + /** + * Unregisters a client from the core. + * Call this when a thin client disconnects. + * Returns true if this was the last client (actual disconnect should happen). + * + * @param clientId - The client ID to unregister + * @returns True if this was the last client, false if others remain + */ + unregisterClient(clientId: string): boolean { + logger(`Unregistering client: ${clientId}`); + this.#activeClients.delete(clientId); + const remaining = this.#activeClients.size; + logger(`Remaining clients: ${remaining}`); + return remaining === 0; + } + + /** + * Gets the number of currently registered clients. + * + * @returns The number of active clients + */ + getClientCount(): number { + return this.#activeClients.size; + } + // DRY THIS WITH REQUEST ROUTER openDeeplinkIfNeeded(): void { const { ui, mobile } = this.options; diff --git a/packages/connect-solana/src/connect.test.ts b/packages/connect-solana/src/connect.test.ts index a72a343e..e312a920 100644 --- a/packages/connect-solana/src/connect.test.ts +++ b/packages/connect-solana/src/connect.test.ts @@ -25,9 +25,21 @@ describe('createSolanaClient', () => { debug: true, }; + // Track registered clients for testing + const registeredClients = new Map(); + const mockCore = { provider: {}, disconnect: vi.fn().mockResolvedValue(undefined), + // Client registration methods (for singleton pattern) + registerClient: vi.fn((clientId: string, sdkType: string) => { + registeredClients.set(clientId, { clientId, sdkType }); + }), + unregisterClient: vi.fn((clientId: string) => { + registeredClients.delete(clientId); + return registeredClients.size === 0; + }), + getClientCount: vi.fn(() => registeredClients.size), }; const mockWallet = { @@ -37,6 +49,7 @@ describe('createSolanaClient', () => { beforeEach(() => { vi.clearAllMocks(); + registeredClients.clear(); (createMultichainClient as ReturnType).mockResolvedValue( mockCore, ); @@ -154,13 +167,30 @@ describe('createSolanaClient', () => { }); describe('disconnect', () => { - it('should disconnect using core.disconnect', async () => { + it('should disconnect using core.disconnect when no other clients registered', async () => { const client = await createSolanaClient(mockOptions); await client.disconnect(); + // When not registered, disconnect should still call core.disconnect expect(mockCore.disconnect).toHaveBeenCalled(); }); + + it('should not disconnect core when other clients remain', async () => { + const client = await createSolanaClient(mockOptions); + + // Register the wallet first (which registers the client) + await client.registerWallet(); + + // Manually add another client to simulate EVM being connected + registeredClients.set('evm-test', { clientId: 'evm-test', sdkType: 'evm' }); + + // Now disconnect - should unregister but not disconnect core + await client.disconnect(); + + expect(mockCore.unregisterClient).toHaveBeenCalled(); + expect(mockCore.disconnect).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/packages/connect-solana/src/connect.ts b/packages/connect-solana/src/connect.ts index faffbf4b..0c986b0a 100644 --- a/packages/connect-solana/src/connect.ts +++ b/packages/connect-solana/src/connect.ts @@ -69,12 +69,33 @@ export async function createSolanaClient( const client = core.provider; + // Generate a unique client ID for this Solana client instance + const clientId = `solana-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + let isRegistered = false; + return { core, getWallet: (walletName?: string) => getWalletStandard({ client, walletName }), - registerWallet: async (walletName = 'MetaMask Connect') => - registerSolanaWalletStandard({ client, walletName }), - disconnect: async () => await core.disconnect(), + registerWallet: async (walletName = 'MetaMask Connect') => { + // Register this client when the wallet is registered (connects) + if (!isRegistered) { + core.registerClient(clientId, 'solana'); + isRegistered = true; + } + return registerSolanaWalletStandard({ client, walletName }); + }, + disconnect: async () => { + // Unregister this client from the core + const isLastClient = isRegistered + ? core.unregisterClient(clientId) + : true; + isRegistered = false; + + // Only actually disconnect if this was the last client + if (isLastClient) { + await core.disconnect(); + } + }, }; } diff --git a/playground/browser-playground/src/experiments/Experiment1.tsx b/playground/browser-playground/src/experiments/Experiment1.tsx index 1577b39c..923c9de9 100644 --- a/playground/browser-playground/src/experiments/Experiment1.tsx +++ b/playground/browser-playground/src/experiments/Experiment1.tsx @@ -15,8 +15,8 @@ * - Storage shows prefixed keys */ import { useState, useEffect, useCallback, useRef } from 'react'; -import type { MultichainCore, SessionData, Scope } from '@metamask/connect'; -import { createMultichainClient } from '@metamask/connect'; +import type { MultichainCore, SessionData, Scope } from '@metamask/connect-multichain'; +import { createMultichainClient } from '@metamask/connect-multichain'; import { ConnectionCard, ActionButton } from './shared'; const DAPP_CONFIG = { diff --git a/playground/browser-playground/src/experiments/Experiment2.tsx b/playground/browser-playground/src/experiments/Experiment2.tsx index 24c5b84c..010ff76e 100644 --- a/playground/browser-playground/src/experiments/Experiment2.tsx +++ b/playground/browser-playground/src/experiments/Experiment2.tsx @@ -14,8 +14,8 @@ * - Multi-tab: Tab A and Tab B show same state */ import { useState, useEffect, useCallback, useRef } from 'react'; -import type { MultichainCore, SessionData, Scope } from '@metamask/connect'; -import { createMultichainClient } from '@metamask/connect'; +import type { MultichainCore, SessionData, Scope } from '@metamask/connect-multichain'; +import { createMultichainClient } from '@metamask/connect-multichain'; import { ConnectionCard, ActionButton } from './shared'; const DAPP_CONFIG = { diff --git a/playground/browser-playground/src/experiments/Experiment3.tsx b/playground/browser-playground/src/experiments/Experiment3.tsx index 49a64ac5..da5e3923 100644 --- a/playground/browser-playground/src/experiments/Experiment3.tsx +++ b/playground/browser-playground/src/experiments/Experiment3.tsx @@ -15,10 +15,10 @@ * - Different storage key prefixes visible */ import { useState, useEffect, useCallback, useRef } from 'react'; -import type { MultichainCore, SessionData, Scope } from '@metamask/connect'; -import { createMultichainClient } from '@metamask/connect'; -import type { MetamaskConnectEVM } from '@metamask/connect/evm'; -import { createEVMClient } from '@metamask/connect/evm'; +import type { MultichainCore, SessionData, Scope } from '@metamask/connect-multichain'; +import { createMultichainClient } from '@metamask/connect-multichain'; +import type { MetamaskConnectEVM } from '@metamask/connect-evm'; +import { createEVMClient } from '@metamask/connect-evm'; import { ConnectionCard, ActionButton } from './shared'; const DAPP_CONFIG = { diff --git a/playground/browser-playground/src/experiments/Experiment8.tsx b/playground/browser-playground/src/experiments/Experiment8.tsx new file mode 100644 index 00000000..7ed8494c --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment8.tsx @@ -0,0 +1,325 @@ +import { useState, useCallback, useRef } from 'react'; + +import { + hasCachedCore, + getCachedCore, +} from '@metamask/connect-multichain'; +import { + createEVMClient, + type MetamaskConnectEVM, +} from '@metamask/connect-evm'; + +import { ConnectionCard, ActionButton, type ConnectionStatus } from './shared'; + +const DAPP_NAME = 'Experiment 8 - Disconnect Coordination'; + +/** + * Experiment 8: Disconnect Coordination + * + * This experiment verifies that: + * 1. Each client must call connect() to register itself + * 2. Disconnecting one client doesn't disconnect others + * 3. Only when the last client disconnects is the session revoked + * 4. Client registration and reference counting work correctly + */ +export function Experiment8() { + const [clientCount, setClientCount] = useState(0); + const [hasCached, setHasCached] = useState(hasCachedCore()); + const [logs, setLogs] = useState([]); + const [error, setError] = useState(null); + + // Track per-client state (since the shared core status doesn't tell us per-client info) + const [client1Created, setClient1Created] = useState(false); + const [client2Created, setClient2Created] = useState(false); + const [client1Connected, setClient1Connected] = useState(false); + const [client2Connected, setClient2Connected] = useState(false); + const [client1Accounts, setClient1Accounts] = useState([]); + const [client2Accounts, setClient2Accounts] = useState([]); + const [client1ChainId, setClient1ChainId] = useState(); + const [client2ChainId, setClient2ChainId] = useState(); + + const evmClient1Ref = useRef(null); + const evmClient2Ref = useRef(null); + + const addLog = useCallback((message: string) => { + setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]); + }, []); + + const refreshState = useCallback(() => { + setHasCached(hasCachedCore()); + const core = getCachedCore(); + if (core && typeof (core as { getClientCount?: () => number }).getClientCount === 'function') { + const count = (core as { getClientCount: () => number }).getClientCount(); + setClientCount(count); + addLog(`Refreshed: Core exists, ${count} registered clients`); + } else { + setClientCount(0); + addLog('Refreshed: No core or no getClientCount method'); + } + }, [addLog]); + + const createClient1 = useCallback(async () => { + try { + setError(null); + addLog('Creating EVM Client 1...'); + const client = await createEVMClient({ + dapp: { + name: DAPP_NAME, + url: window.location.href, + }, + api: { + supportedNetworks: { + '0x1': 'https://mainnet.infura.io/v3/YOUR_KEY', + }, + }, + }); + evmClient1Ref.current = client; + setClient1Created(true); + addLog('EVM Client 1 created (not yet connected)'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } + }, [addLog, refreshState]); + + const createClient2 = useCallback(async () => { + try { + setError(null); + addLog('Creating EVM Client 2...'); + const client = await createEVMClient({ + dapp: { + name: DAPP_NAME, + url: window.location.href, + }, + api: { + supportedNetworks: { + '0x1': 'https://mainnet.infura.io/v3/YOUR_KEY', + }, + }, + }); + evmClient2Ref.current = client; + setClient2Created(true); + addLog('EVM Client 2 created (not yet connected)'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } + }, [addLog, refreshState]); + + const connectClient1 = useCallback(async () => { + if (!evmClient1Ref.current) return; + try { + setError(null); + addLog('Connecting Client 1...'); + const result = await evmClient1Ref.current.connect({ chainIds: ['0x1'] }); + setClient1Connected(true); + setClient1Accounts(result.accounts); + setClient1ChainId(result.chainId); + addLog(`Client 1 connected! Accounts: ${result.accounts.join(', ')}, Chain: ${result.chainId}`); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error connecting Client 1: ${msg}`); + } + }, [addLog, refreshState]); + + const connectClient2 = useCallback(async () => { + if (!evmClient2Ref.current) return; + try { + setError(null); + addLog('Connecting Client 2...'); + const result = await evmClient2Ref.current.connect({ chainIds: ['0x1'] }); + setClient2Connected(true); + setClient2Accounts(result.accounts); + setClient2ChainId(result.chainId); + addLog(`Client 2 connected! Accounts: ${result.accounts.join(', ')}, Chain: ${result.chainId}`); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error connecting Client 2: ${msg}`); + } + }, [addLog, refreshState]); + + const disconnectClient1 = useCallback(async () => { + if (!evmClient1Ref.current) return; + try { + setError(null); + addLog('Disconnecting Client 1...'); + await evmClient1Ref.current.disconnect(); + setClient1Connected(false); + setClient1Accounts([]); + setClient1ChainId(undefined); + addLog('Client 1 disconnected'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error disconnecting Client 1: ${msg}`); + } + }, [addLog, refreshState]); + + const disconnectClient2 = useCallback(async () => { + if (!evmClient2Ref.current) return; + try { + setError(null); + addLog('Disconnecting Client 2...'); + await evmClient2Ref.current.disconnect(); + setClient2Connected(false); + setClient2Accounts([]); + setClient2ChainId(undefined); + addLog('Client 2 disconnected'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error disconnecting Client 2: ${msg}`); + } + }, [addLog, refreshState]); + + const clearLogs = useCallback(() => { + setLogs([]); + }, []); + + // Helper to get status for ConnectionCard + const getStatus = (created: boolean, connected: boolean): ConnectionStatus => { + if (!created) return 'disconnected'; + if (connected) return 'connected'; + return 'disconnected'; + }; + + return ( +
+

Experiment 8: Disconnect Coordination

+

+ Verifies that disconnecting one client doesn't disconnect others. + Only when the last client disconnects is the session actually revoked. +

+ + {/* State Display */} +
+

Core State

+ + + + + + + + + + + +
Has Cached Core:{hasCached ? '✅ Yes' : '❌ No'}
Registered Client Count:{clientCount}
+
+ + Refresh State + +
+
+ + {/* Client Cards */} +
+ {/* EVM Client 1 */} + +
+ {!client1Created && ( + + Create Client 1 + + )} + {client1Created && !client1Connected && ( + + Connect + + )} + {client1Created && client1Connected && ( + + Disconnect + + )} +
+
+ + {/* EVM Client 2 */} + +
+ {!client2Created && ( + + Create Client 2 + + )} + {client2Created && !client2Connected && ( + + Connect + + )} + {client2Created && client2Connected && ( + + Disconnect + + )} +
+
+
+ + {/* Logs */} +
+
+ Activity Log + +
+ {logs.length === 0 ? ( +
No activity yet...
+ ) : ( + logs.map((log, i) =>
{log}
) + )} +
+ + {/* Error Display */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Test Steps */} +
+

+ Test Steps +

+
    +
  1. Create both Client 1 and Client 2
  2. +
  3. Connect Client 1 (scan QR or use extension) → Count = 1
  4. +
  5. Connect Client 2 (should connect instantly) → Count = 2
  6. +
  7. Disconnect Client 1 → Count = 1, Client 2 still connected!
  8. +
  9. Disconnect Client 2 → Count = 0, session revoked
  10. +
+
+ Note: Each client must call connect() to register. + Creating a client doesn't register it - the count only increases when connect() is called. +
+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/ExperimentsApp.tsx b/playground/browser-playground/src/experiments/ExperimentsApp.tsx index 2d7c2544..0f8acd2e 100644 --- a/playground/browser-playground/src/experiments/ExperimentsApp.tsx +++ b/playground/browser-playground/src/experiments/ExperimentsApp.tsx @@ -7,11 +7,12 @@ import { Experiment4 } from './Experiment4'; import { Experiment5 } from './Experiment5'; import { Experiment6 } from './Experiment6'; import { Experiment7 } from './Experiment7'; +import { Experiment8 } from './Experiment8'; // Get experiment from URL hash, default to exp1 function getExperimentFromHash(): ExperimentId { const hash = window.location.hash.slice(1); - if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7'].includes(hash)) { + if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7', 'exp8'].includes(hash)) { return hash as ExperimentId; } return 'exp1'; @@ -59,6 +60,8 @@ export function ExperimentsApp() { return ; case 'exp7': return ; + case 'exp8': + return ; default: return ; } diff --git a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx index b291bb56..02cee762 100644 --- a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx +++ b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx @@ -8,7 +8,8 @@ export type ExperimentId = | 'exp4' | 'exp5' | 'exp6' - | 'exp7'; + | 'exp7' + | 'exp8'; type ExperimentInfo = { id: ExperimentId; @@ -52,6 +53,11 @@ export const EXPERIMENTS: ExperimentInfo[] = [ title: 'Exp 7: Core Sharing', description: 'Verify singleton core is shared across SDK types', }, + { + id: 'exp8', + title: 'Exp 8: Disconnect Coordination', + description: 'Verify disconnect only revokes session when last client disconnects', + }, ]; type ExperimentsLayoutProps = { From 1ccd8b52aa8a2a6cf27d55f613bf46e9bdeb0ad5 Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 5 Feb 2026 13:39:32 +0100 Subject: [PATCH 3/4] feat: implement scope tracking and merging on connect (Phase 3) - Add areScopesCovered() and mergeScopes() utility functions - Track scopes per client in ClientInfo type - Add getUnionScopes() method to MultichainCore - Modify transport connect() to skip prompts when scopes already covered - Merge scopes instead of revoking when expanding permissions - Add loading state and spinner for QR code approval flow - Create Experiment 9 to verify scope merging behavior - Update tests to reflect new scope merging behavior When a client reconnects and its requested scopes are already covered by the existing session, no approval prompt is shown. New scopes are merged with existing ones without revoking the session. Co-authored-by: Cursor --- packages/connect-evm/src/connect.test.ts | 19 +- packages/connect-evm/src/connect.ts | 4 +- .../src/domain/multichain/index.ts | 12 +- .../src/multichain/index.ts | 21 +- .../multichain/transports/default/index.ts | 51 ++- .../src/multichain/transports/mwp/index.ts | 55 +-- .../src/multichain/utils/index.test.ts | 110 +++++ .../src/multichain/utils/index.ts | 31 ++ .../connect-multichain/src/session.test.ts | 7 +- packages/connect-solana/src/connect.test.ts | 21 +- packages/connect-solana/src/connect.ts | 6 +- .../src/experiments/Experiment9.tsx | 386 ++++++++++++++++++ .../src/experiments/ExperimentsApp.tsx | 5 +- .../experiments/shared/ExperimentsLayout.tsx | 8 +- 14 files changed, 674 insertions(+), 62 deletions(-) create mode 100644 playground/browser-playground/src/experiments/Experiment9.tsx diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index 8942233f..7fd33043 100644 --- a/packages/connect-evm/src/connect.test.ts +++ b/packages/connect-evm/src/connect.test.ts @@ -28,8 +28,8 @@ function createMockCore() { }, }; - // Track registered clients for testing - const registeredClients = new Map(); + // Track registered clients for testing (with scopes) + const registeredClients = new Map(); const mockCore: Partial = { // Delegate event methods to the real emitter @@ -66,15 +66,24 @@ function createMockCore() { disconnect: vi.fn().mockResolvedValue(undefined), - // Client registration methods (for singleton pattern) - registerClient: vi.fn((clientId: string, sdkType: string) => { - registeredClients.set(clientId, { clientId, sdkType }); + // Client registration methods (for singleton pattern with scope tracking) + registerClient: vi.fn((clientId: string, sdkType: string, scopes: string[]) => { + registeredClients.set(clientId, { clientId, sdkType, scopes }); }), unregisterClient: vi.fn((clientId: string) => { registeredClients.delete(clientId); return registeredClients.size === 0; }), getClientCount: vi.fn(() => registeredClients.size), + getUnionScopes: vi.fn(() => { + const allScopes = new Set(); + for (const client of registeredClients.values()) { + for (const scope of client.scopes) { + allScopes.add(scope); + } + } + return Array.from(allScopes); + }), transport: mockTransport as any, storage: mockStorage as any, diff --git a/packages/connect-evm/src/connect.ts b/packages/connect-evm/src/connect.ts index 8f3af6cf..19c3a1ea 100644 --- a/packages/connect-evm/src/connect.ts +++ b/packages/connect-evm/src/connect.ts @@ -348,9 +348,9 @@ export class MetamaskConnectEVM { forceRequest, ); - // Register this client with the core (for reference counting) + // Register this client with the core (for reference counting and scope tracking) if (!this.#isRegistered) { - this.#core.registerClient(this.#clientId, 'evm'); + this.#core.registerClient(this.#clientId, 'evm', caipChainIds as Scope[]); this.#isRegistered = true; } diff --git a/packages/connect-multichain/src/domain/multichain/index.ts b/packages/connect-multichain/src/domain/multichain/index.ts index 24baee5d..7876f2b2 100644 --- a/packages/connect-multichain/src/domain/multichain/index.ts +++ b/packages/connect-multichain/src/domain/multichain/index.ts @@ -34,6 +34,8 @@ export type ClientInfo = { sdkType: string; /** When the client was registered */ registeredAt: number; + /** The scopes this client has requested */ + scopes: Scope[]; }; /** @@ -88,8 +90,16 @@ export abstract class MultichainCore extends EventEmitter { * * @param clientId - Unique identifier for the client * @param sdkType - The SDK type (e.g., 'evm', 'solana') + * @param scopes - The scopes this client has requested */ - abstract registerClient(clientId: string, sdkType: string): void; + abstract registerClient(clientId: string, sdkType: string, scopes: Scope[]): void; + + /** + * Gets the union of all scopes from all registered clients. + * + * @returns Array of unique scopes from all clients + */ + abstract getUnionScopes(): Scope[]; /** * Unregisters a client from the core. diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index b08ef155..6aadfb24 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -851,17 +851,34 @@ export class MetaMaskConnectMultichain extends MultichainCore { * * @param clientId - Unique identifier for the client * @param sdkType - The SDK type (e.g., 'evm', 'solana') + * @param scopes - The scopes this client has requested */ - registerClient(clientId: string, sdkType: string): void { - logger(`Registering client: ${clientId} (${sdkType})`); + registerClient(clientId: string, sdkType: string, scopes: Scope[]): void { + logger(`Registering client: ${clientId} (${sdkType}) with scopes: ${scopes.join(', ')}`); this.#activeClients.set(clientId, { clientId, sdkType, registeredAt: Date.now(), + scopes, }); logger(`Active clients: ${this.#activeClients.size}`); } + /** + * Gets the union of all scopes from all registered clients. + * + * @returns Array of unique scopes from all clients + */ + getUnionScopes(): Scope[] { + const allScopes = new Set(); + for (const client of this.#activeClients.values()) { + for (const scope of client.scopes) { + allScopes.add(scope); + } + } + return Array.from(allScopes); + } + /** * Unregisters a client from the core. * Call this when a thin client disconnects. diff --git a/packages/connect-multichain/src/multichain/transports/default/index.ts b/packages/connect-multichain/src/multichain/transports/default/index.ts index bd9d139d..970b90a4 100644 --- a/packages/connect-multichain/src/multichain/transports/default/index.ts +++ b/packages/connect-multichain/src/multichain/transports/default/index.ts @@ -12,9 +12,10 @@ import type { ExtendedTransport, RPCAPI, Scope, SessionData } from 'src/domain'; import { addValidAccounts, + areScopesCovered, getOptionalScopes, getValidAccounts, - isSameScopesAndAccounts, + mergeScopes, } from '../../utils'; const DEFAULT_REQUEST_TIMEOUT = 60 * 1000; @@ -220,28 +221,42 @@ export class DefaultTransport implements ExtendedTransport { walletSession?.sessionScopes ?? {}, ) as Scope[]; const proposedScopes = options?.scopes ?? []; - const proposedCaipAccountIds = options?.caipAccountIds ?? []; - const hasSameScopesAndAccounts = isSameScopesAndAccounts( + + // Check if all proposed scopes are already covered by current session + const scopesAlreadyCovered = areScopesCovered( currentScopes, proposedScopes, - walletSession, - proposedCaipAccountIds, ); - if (!hasSameScopesAndAccounts) { - await this.request( - { method: 'wallet_revokeSession', params: walletSession }, - this.#defaultRequestOptions, - ); - const response = await this.request( - { method: 'wallet_createSession', params: createSessionParams }, - this.#defaultRequestOptions, - ); - if (response.error) { - throw new Error(response.error.message); - } - walletSession = response.result as SessionData; + if (scopesAlreadyCovered) { + // Scopes already covered by existing session - just notify and return + // No need to prompt user again since the wallet already has these permissions + this.#notifyCallbacks({ + method: 'wallet_sessionChanged', + params: walletSession, + }); + return; } + + // Need to expand scopes - merge current and proposed scopes + // instead of revoking and recreating (for singleton/multi-client support) + const mergedScopes = mergeScopes(currentScopes, proposedScopes); + const mergedSessionParams: CreateSessionParams = { + optionalScopes: addValidAccounts( + getOptionalScopes(mergedScopes), + getValidAccounts(options?.caipAccountIds ?? []), + ), + sessionProperties: options?.sessionProperties, + }; + + const response = await this.request( + { method: 'wallet_createSession', params: mergedSessionParams }, + this.#defaultRequestOptions, + ); + if (response.error) { + throw new Error(response.error.message); + } + walletSession = response.result as SessionData; } else if (!walletSession || options?.forceRequest) { const response = await this.request( { method: 'wallet_createSession', params: createSessionParams }, diff --git a/packages/connect-multichain/src/multichain/transports/mwp/index.ts b/packages/connect-multichain/src/multichain/transports/mwp/index.ts index 2d4c5619..c039a653 100644 --- a/packages/connect-multichain/src/multichain/transports/mwp/index.ts +++ b/packages/connect-multichain/src/multichain/transports/mwp/index.ts @@ -38,9 +38,10 @@ import { } from '../../../domain'; import { addValidAccounts, + areScopesCovered, getOptionalScopes, getValidAccounts, - isSameScopesAndAccounts, + mergeScopes, } from '../../utils'; import { MULTICHAIN_PROVIDER_STREAM_NAME } from '../constants'; @@ -249,33 +250,41 @@ export class MWPTransport implements ExtendedTransport { walletSession?.sessionScopes ?? {}, ) as Scope[]; const proposedScopes = options?.scopes ?? []; - const proposedCaipAccountIds = options?.caipAccountIds ?? []; - const hasSameScopesAndAccounts = isSameScopesAndAccounts( + + // Check if all proposed scopes are already covered by current session + const scopesAlreadyCovered = areScopesCovered( currentScopes, proposedScopes, - walletSession, - proposedCaipAccountIds, ); - if (!hasSameScopesAndAccounts) { - const optionalScopes = addValidAccounts( - getOptionalScopes(options?.scopes ?? []), - getValidAccounts(options?.caipAccountIds ?? []), - ); - const sessionRequest: CreateSessionParams = { - optionalScopes, - }; - const response = await this.request({ - method: 'wallet_createSession', - params: sessionRequest, + + if (scopesAlreadyCovered) { + // Scopes already covered by existing session - just notify and return + // No need to prompt user again since the wallet already has these permissions + this.notifyCallbacks({ + method: 'wallet_sessionChanged', + params: walletSession, }); - if (response.error) { - return resumeReject(new Error(response.error.message)); - } - // TODO: Maybe find a better way to revoke sessions on wallet without triggering an empty notification - // Issue of this is it will send a session update event with an empty session and right after we may get the session recovered - // await this.request({ method: 'wallet_revokeSession', params: walletSession }); - walletSession = response.result as SessionData; + return resumeResolve(); + } + + // Need to expand scopes - merge current and proposed scopes + // instead of revoking and recreating (for singleton/multi-client support) + const mergedScopes = mergeScopes(currentScopes, proposedScopes); + const optionalScopes = addValidAccounts( + getOptionalScopes(mergedScopes), + getValidAccounts(options?.caipAccountIds ?? []), + ); + const createSessionRequest: CreateSessionParams = { + optionalScopes, + }; + const response = await this.request({ + method: 'wallet_createSession', + params: createSessionRequest, + }); + if (response.error) { + return resumeReject(new Error(response.error.message)); } + walletSession = response.result as SessionData; } else if (!walletSession) { // TODO: verify if this branching logic can ever be hit const optionalScopes = addValidAccounts( diff --git a/packages/connect-multichain/src/multichain/utils/index.test.ts b/packages/connect-multichain/src/multichain/utils/index.test.ts index 3725f4d6..805b7a30 100644 --- a/packages/connect-multichain/src/multichain/utils/index.test.ts +++ b/packages/connect-multichain/src/multichain/utils/index.test.ts @@ -496,4 +496,114 @@ t.describe('Utils', () => { t.expect(result).toBe(true); }); }); + + t.describe('areScopesCovered', () => { + t.it('should return true when proposed scopes are a subset of current scopes', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137', 'solana:mainnet']; + const proposedScopes: Scope[] = ['eip155:1']; + + const result = utils.areScopesCovered(currentScopes, proposedScopes); + + t.expect(result).toBe(true); + }); + + t.it('should return true when proposed scopes equal current scopes', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; + + const result = utils.areScopesCovered(currentScopes, proposedScopes); + + t.expect(result).toBe(true); + }); + + t.it('should return false when proposed scopes include new scopes', () => { + const currentScopes: Scope[] = ['eip155:1']; + const proposedScopes: Scope[] = ['eip155:1', 'solana:mainnet']; + + const result = utils.areScopesCovered(currentScopes, proposedScopes); + + t.expect(result).toBe(false); + }); + + t.it('should return false when proposed scopes are entirely different', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['solana:mainnet']; + + const result = utils.areScopesCovered(currentScopes, proposedScopes); + + t.expect(result).toBe(false); + }); + + t.it('should return true when proposed scopes are empty', () => { + const currentScopes: Scope[] = ['eip155:1']; + const proposedScopes: Scope[] = []; + + const result = utils.areScopesCovered(currentScopes, proposedScopes); + + t.expect(result).toBe(true); + }); + + t.it('should return true when both are empty', () => { + const currentScopes: Scope[] = []; + const proposedScopes: Scope[] = []; + + const result = utils.areScopesCovered(currentScopes, proposedScopes); + + t.expect(result).toBe(true); + }); + }); + + t.describe('mergeScopes', () => { + t.it('should merge non-overlapping scopes', () => { + const currentScopes: Scope[] = ['eip155:1']; + const proposedScopes: Scope[] = ['solana:mainnet']; + + const result = utils.mergeScopes(currentScopes, proposedScopes); + + t.expect(result).toHaveLength(2); + t.expect(result).toContain('eip155:1'); + t.expect(result).toContain('solana:mainnet'); + }); + + t.it('should deduplicate overlapping scopes', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'solana:mainnet']; + + const result = utils.mergeScopes(currentScopes, proposedScopes); + + t.expect(result).toHaveLength(3); + t.expect(result).toContain('eip155:1'); + t.expect(result).toContain('eip155:137'); + t.expect(result).toContain('solana:mainnet'); + }); + + t.it('should return current scopes when proposed is empty', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = []; + + const result = utils.mergeScopes(currentScopes, proposedScopes); + + t.expect(result).toEqual(['eip155:1', 'eip155:137']); + }); + + t.it('should return proposed scopes when current is empty', () => { + const currentScopes: Scope[] = []; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; + + const result = utils.mergeScopes(currentScopes, proposedScopes); + + t.expect(result).toEqual(['eip155:1', 'eip155:137']); + }); + + t.it('should return same scopes when both are identical', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; + + const result = utils.mergeScopes(currentScopes, proposedScopes); + + t.expect(result).toHaveLength(2); + t.expect(result).toContain('eip155:1'); + t.expect(result).toContain('eip155:137'); + }); + }); }); diff --git a/packages/connect-multichain/src/multichain/utils/index.ts b/packages/connect-multichain/src/multichain/utils/index.ts index aecb2a6a..6eb19308 100644 --- a/packages/connect-multichain/src/multichain/utils/index.ts +++ b/packages/connect-multichain/src/multichain/utils/index.ts @@ -242,6 +242,37 @@ export function isSameScopesAndAccounts( return allProposedAccountsIncluded; } +/** + * Checks if proposed scopes are already covered by current session scopes. + * This is used to determine if we need to request additional permissions. + * + * @param currentScopes - Current scopes from the existing session + * @param proposedScopes - Proposed scopes from the connect options + * @returns true if all proposed scopes are already in current scopes + */ +export function areScopesCovered( + currentScopes: Scope[], + proposedScopes: Scope[], +): boolean { + return proposedScopes.every((scope) => currentScopes.includes(scope)); +} + +/** + * Computes the union of current and proposed scopes. + * Used when merging scopes from multiple clients. + * + * @param currentScopes - Current scopes from the existing session + * @param proposedScopes - Proposed scopes from the connect options + * @returns Array of unique scopes combining both sets + */ +export function mergeScopes( + currentScopes: Scope[], + proposedScopes: Scope[], +): Scope[] { + const scopeSet = new Set([...currentScopes, ...proposedScopes]); + return Array.from(scopeSet); +} + /** * * @param caipAccountIds diff --git a/packages/connect-multichain/src/session.test.ts b/packages/connect-multichain/src/session.test.ts index 033232ae..a3494c2e 100644 --- a/packages/connect-multichain/src/session.test.ts +++ b/packages/connect-multichain/src/session.test.ts @@ -164,10 +164,13 @@ function testSuite({ { timeout: 60 * 1000 }, ); - t.expect(mockedData.mockDefaultTransport.request).toHaveBeenCalledWith( + // With scope merging, we no longer revoke before creating a new session. + // Instead, we call wallet_createSession with the merged scopes. + t.expect( + mockedData.mockDefaultTransport.request, + ).not.toHaveBeenCalledWith( t.expect.objectContaining({ method: 'wallet_revokeSession', - params: mockSessionData, }), { timeout: 60 * 1000 }, ); diff --git a/packages/connect-solana/src/connect.test.ts b/packages/connect-solana/src/connect.test.ts index e312a920..36678179 100644 --- a/packages/connect-solana/src/connect.test.ts +++ b/packages/connect-solana/src/connect.test.ts @@ -25,21 +25,30 @@ describe('createSolanaClient', () => { debug: true, }; - // Track registered clients for testing - const registeredClients = new Map(); + // Track registered clients for testing (with scopes) + const registeredClients = new Map(); const mockCore = { provider: {}, disconnect: vi.fn().mockResolvedValue(undefined), - // Client registration methods (for singleton pattern) - registerClient: vi.fn((clientId: string, sdkType: string) => { - registeredClients.set(clientId, { clientId, sdkType }); + // Client registration methods (for singleton pattern with scope tracking) + registerClient: vi.fn((clientId: string, sdkType: string, scopes: string[]) => { + registeredClients.set(clientId, { clientId, sdkType, scopes }); }), unregisterClient: vi.fn((clientId: string) => { registeredClients.delete(clientId); return registeredClients.size === 0; }), getClientCount: vi.fn(() => registeredClients.size), + getUnionScopes: vi.fn(() => { + const allScopes = new Set(); + for (const client of registeredClients.values()) { + for (const scope of client.scopes) { + allScopes.add(scope); + } + } + return Array.from(allScopes); + }), }; const mockWallet = { @@ -183,7 +192,7 @@ describe('createSolanaClient', () => { await client.registerWallet(); // Manually add another client to simulate EVM being connected - registeredClients.set('evm-test', { clientId: 'evm-test', sdkType: 'evm' }); + registeredClients.set('evm-test', { clientId: 'evm-test', sdkType: 'evm', scopes: ['eip155:1'] }); // Now disconnect - should unregister but not disconnect core await client.disconnect(); diff --git a/packages/connect-solana/src/connect.ts b/packages/connect-solana/src/connect.ts index 0c986b0a..b9f81f97 100644 --- a/packages/connect-solana/src/connect.ts +++ b/packages/connect-solana/src/connect.ts @@ -72,6 +72,10 @@ export async function createSolanaClient( // Generate a unique client ID for this Solana client instance const clientId = `solana-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; let isRegistered = false; + + // Get the scopes (CAIP chain IDs) from supported networks + // These are already in CAIP format (e.g., 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') + const solanaScopes = Object.keys(supportedNetworks) as Array<`solana:${string}`>; return { core, @@ -80,7 +84,7 @@ export async function createSolanaClient( registerWallet: async (walletName = 'MetaMask Connect') => { // Register this client when the wallet is registered (connects) if (!isRegistered) { - core.registerClient(clientId, 'solana'); + core.registerClient(clientId, 'solana', solanaScopes); isRegistered = true; } return registerSolanaWalletStandard({ client, walletName }); diff --git a/playground/browser-playground/src/experiments/Experiment9.tsx b/playground/browser-playground/src/experiments/Experiment9.tsx new file mode 100644 index 00000000..97d743ec --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment9.tsx @@ -0,0 +1,386 @@ +import { useState, useCallback, useRef } from 'react'; + +import { + hasCachedCore, + getCachedCore, + type MultichainCore, +} from '@metamask/connect-multichain'; +import { + createEVMClient, + type MetamaskConnectEVM, +} from '@metamask/connect-evm'; + +import { ConnectionCard, ActionButton, type ConnectionStatus } from './shared'; + +const DAPP_NAME = 'Experiment 9 - Scope Merging'; + +/** + * Experiment 9: Scope Merging on Connect + * + * This experiment verifies Phase 3: Scope Tracking & Merging: + * 1. EVM Client 1 connects with chainIds [1] (eip155:1) + * 2. EVM Client 2 connects with chainIds [1, 137] (eip155:1, eip155:137) + * 3. Expected: Scopes are merged to [eip155:1, eip155:137], no revoke happens + * 4. The getUnionScopes() method returns the merged scopes + */ +export function Experiment9() { + const [clientCount, setClientCount] = useState(0); + const [hasCached, setHasCached] = useState(hasCachedCore()); + const [unionScopes, setUnionScopes] = useState([]); + const [logs, setLogs] = useState([]); + const [error, setError] = useState(null); + + // Track per-client state + const [client1Created, setClient1Created] = useState(false); + const [client2Created, setClient2Created] = useState(false); + const [client1Connected, setClient1Connected] = useState(false); + const [client2Connected, setClient2Connected] = useState(false); + const [client1Connecting, setClient1Connecting] = useState(false); + const [client2Connecting, setClient2Connecting] = useState(false); + const [client1Accounts, setClient1Accounts] = useState([]); + const [client2Accounts, setClient2Accounts] = useState([]); + const [client1ChainId, setClient1ChainId] = useState(); + const [client2ChainId, setClient2ChainId] = useState(); + + const evmClient1Ref = useRef(null); + const evmClient2Ref = useRef(null); + + const addLog = useCallback((message: string) => { + setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]); + }, []); + + const refreshState = useCallback(() => { + setHasCached(hasCachedCore()); + const core = getCachedCore() as MultichainCore & { + getClientCount?: () => number; + getUnionScopes?: () => string[]; + } | undefined; + + if (core && typeof core.getClientCount === 'function') { + const count = core.getClientCount(); + setClientCount(count); + + if (typeof core.getUnionScopes === 'function') { + const scopes = core.getUnionScopes(); + setUnionScopes(scopes); + addLog(`Refreshed: Core exists, ${count} clients, union scopes: [${scopes.join(', ')}]`); + } else { + addLog(`Refreshed: Core exists, ${count} clients, no getUnionScopes method`); + } + } else { + setClientCount(0); + setUnionScopes([]); + addLog('Refreshed: No core or no getClientCount method'); + } + }, [addLog]); + + const createClient1 = useCallback(async () => { + try { + setError(null); + addLog('Creating EVM Client 1 (will request eip155:1)...'); + const client = await createEVMClient({ + dapp: { + name: DAPP_NAME, + url: window.location.href, + }, + api: { + supportedNetworks: { + '0x1': 'https://mainnet.infura.io/v3/YOUR_KEY', + }, + }, + }); + evmClient1Ref.current = client; + setClient1Created(true); + addLog('EVM Client 1 created (not yet connected)'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error creating Client 1: ${msg}`); + } + }, [addLog, refreshState]); + + const createClient2 = useCallback(async () => { + try { + setError(null); + addLog('Creating EVM Client 2 (will request eip155:1, eip155:137)...'); + const client = await createEVMClient({ + dapp: { + name: DAPP_NAME, + url: window.location.href, + }, + api: { + supportedNetworks: { + '0x1': 'https://mainnet.infura.io/v3/YOUR_KEY', + '0x89': 'https://polygon-mainnet.infura.io/v3/YOUR_KEY', + }, + }, + }); + evmClient2Ref.current = client; + setClient2Created(true); + addLog('EVM Client 2 created (not yet connected)'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error creating Client 2: ${msg}`); + } + }, [addLog, refreshState]); + + const connectClient1 = useCallback(async () => { + if (!evmClient1Ref.current) { + setError('Client 1 not created'); + return; + } + try { + setError(null); + setClient1Connecting(true); + addLog('Connecting Client 1 with chainIds [0x1] (Ethereum Mainnet)...'); + addLog('⏳ Waiting for wallet approval (check your phone if using QR code)...'); + const result = await evmClient1Ref.current.connect({ chainIds: ['0x1'] }); + setClient1Connected(true); + setClient1Accounts(result.accounts); + setClient1ChainId(result.chainId); + addLog(`Client 1 connected: ${result.accounts.length} accounts, chainId: ${result.chainId}`); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error connecting Client 1: ${msg}`); + } finally { + setClient1Connecting(false); + } + }, [addLog, refreshState]); + + const connectClient2 = useCallback(async () => { + if (!evmClient2Ref.current) { + setError('Client 2 not created'); + return; + } + try { + setError(null); + setClient2Connecting(true); + addLog('Connecting Client 2 with chainIds [0x1, 0x89] (Ethereum + Polygon)...'); + addLog('Expected: Should MERGE scopes without revoking existing session'); + addLog('⏳ Waiting for wallet approval (check your phone if using QR code)...'); + const result = await evmClient2Ref.current.connect({ chainIds: ['0x1', '0x89'] }); + setClient2Connected(true); + setClient2Accounts(result.accounts); + setClient2ChainId(result.chainId); + addLog(`Client 2 connected: ${result.accounts.length} accounts, chainId: ${result.chainId}`); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error connecting Client 2: ${msg}`); + } finally { + setClient2Connecting(false); + } + }, [addLog, refreshState]); + + const disconnectClient1 = useCallback(async () => { + if (!evmClient1Ref.current) { + setError('Client 1 not created'); + return; + } + try { + setError(null); + addLog('Disconnecting Client 1...'); + await evmClient1Ref.current.disconnect(); + setClient1Connected(false); + setClient1Accounts([]); + setClient1ChainId(undefined); + addLog('Client 1 disconnected (unregistered from core)'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error disconnecting Client 1: ${msg}`); + } + }, [addLog, refreshState]); + + const disconnectClient2 = useCallback(async () => { + if (!evmClient2Ref.current) { + setError('Client 2 not created'); + return; + } + try { + setError(null); + addLog('Disconnecting Client 2...'); + await evmClient2Ref.current.disconnect(); + setClient2Connected(false); + setClient2Accounts([]); + setClient2ChainId(undefined); + addLog('Client 2 disconnected (unregistered from core)'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error disconnecting Client 2: ${msg}`); + } + }, [addLog, refreshState]); + + const clearLogs = useCallback(() => { + setLogs([]); + }, []); + + // Helper to get status for ConnectionCard + const getStatus = (created: boolean, connected: boolean, connecting: boolean): ConnectionStatus => { + if (!created) return 'disconnected'; + if (connecting) return 'connecting'; + if (connected) return 'connected'; + return 'disconnected'; + }; + + return ( +
+

Experiment 9: Scope Merging on Connect

+

+ Verifies that when a second client connects with different scopes, + they are merged with the existing session instead of revoking and recreating. +

+ + {/* State Display */} +
+

Core State

+ + + + + + + + + + + + + + + +
Has Cached Core:{hasCached ? '✅ Yes' : '❌ No'}
Registered Client Count:{clientCount}
Union of All Scopes: + {unionScopes.length > 0 ? `[${unionScopes.join(', ')}]` : '[]'} +
+
+ + Refresh State + +
+
+ + {/* Expected Behavior */} +
+

Expected Behavior

+
    +
  1. Create Client 1 → Core created, 0 registered clients
  2. +
  3. Connect Client 1 (Ethereum) → 1 registered client, scopes: [eip155:1]
  4. +
  5. Create Client 2 → Still 1 registered client (not connected yet)
  6. +
  7. Connect Client 2 (Ethereum + Polygon) → Should NOT show new QR code prompt
  8. +
  9. After Client 2 connects → 2 registered clients, scopes: [eip155:1, eip155:137]
  10. +
  11. Disconnect Client 1 → 1 registered client, session still active
  12. +
  13. Disconnect Client 2 → 0 registered clients, session revoked
  14. +
+
+ + {/* Client Cards */} +
+ {/* EVM Client 1 */} + +
+ {!client1Created && ( + + Create Client 1 + + )} + {client1Created && !client1Connected && !client1Connecting && ( + + Connect + + )} + {client1Connecting && ( +
+ + + + + Waiting for approval... +
+ )} + {client1Created && client1Connected && ( + + Disconnect + + )} +
+
+ + {/* EVM Client 2 */} + +
+ {!client2Created && ( + + Create Client 2 + + )} + {client2Created && !client2Connected && !client2Connecting && ( + + Connect + + )} + {client2Connecting && ( +
+ + + + + Waiting for approval... +
+ )} + {client2Created && client2Connected && ( + + Disconnect + + )} +
+
+
+ + {/* Logs */} +
+
+ Activity Log + +
+ {logs.length === 0 ? ( +
No activity yet...
+ ) : ( + logs.map((log, i) =>
{log}
) + )} +
+ + {/* Error Display */} + {error && ( +
+ Error: {error} +
+ )} +
+ ); +} diff --git a/playground/browser-playground/src/experiments/ExperimentsApp.tsx b/playground/browser-playground/src/experiments/ExperimentsApp.tsx index 0f8acd2e..0e554ffa 100644 --- a/playground/browser-playground/src/experiments/ExperimentsApp.tsx +++ b/playground/browser-playground/src/experiments/ExperimentsApp.tsx @@ -8,11 +8,12 @@ import { Experiment5 } from './Experiment5'; import { Experiment6 } from './Experiment6'; import { Experiment7 } from './Experiment7'; import { Experiment8 } from './Experiment8'; +import { Experiment9 } from './Experiment9'; // Get experiment from URL hash, default to exp1 function getExperimentFromHash(): ExperimentId { const hash = window.location.hash.slice(1); - if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7', 'exp8'].includes(hash)) { + if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7', 'exp8', 'exp9'].includes(hash)) { return hash as ExperimentId; } return 'exp1'; @@ -62,6 +63,8 @@ export function ExperimentsApp() { return ; case 'exp8': return ; + case 'exp9': + return ; default: return ; } diff --git a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx index 02cee762..b8028b76 100644 --- a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx +++ b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx @@ -9,7 +9,8 @@ export type ExperimentId = | 'exp5' | 'exp6' | 'exp7' - | 'exp8'; + | 'exp8' + | 'exp9'; type ExperimentInfo = { id: ExperimentId; @@ -58,6 +59,11 @@ export const EXPERIMENTS: ExperimentInfo[] = [ title: 'Exp 8: Disconnect Coordination', description: 'Verify disconnect only revokes session when last client disconnects', }, + { + id: 'exp9', + title: 'Exp 9: Scope Merging', + description: 'Verify scopes are merged when connecting clients with different chains', + }, ]; type ExperimentsLayoutProps = { From 151afdd9fd73fa62c8ba8f431e7e826a8e6c8dfb Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 5 Feb 2026 14:10:36 +0100 Subject: [PATCH 4/4] feat: implement partial disconnect with CAIP limitation (Phase 4) - Add updateSessionScopes abstract method to MultichainCore - Implement updateSessionScopes in MetaMaskConnectMultichain - Update connect-evm and connect-solana disconnect to call updateSessionScopes - Add Experiment10 for testing partial disconnect behavior - Document CAIP limitation: no standard for partial scope revocation (wallet retains all granted scopes, SDK tracks remaining client scopes) Note: Per CAIP-25/285 research, wallet_createSession is additive only and wallet_revokeSession is full session revocation. Partial scope reduction is not supported by current CAIP standards. Co-authored-by: Cursor --- packages/connect-evm/src/connect.test.ts | 1 + packages/connect-evm/src/connect.ts | 5 +- .../src/domain/multichain/index.ts | 13 + .../src/multichain/index.ts | 39 ++ packages/connect-solana/src/connect.test.ts | 1 + packages/connect-solana/src/connect.ts | 4 + .../src/experiments/Experiment10.tsx | 358 ++++++++++++++++++ .../src/experiments/ExperimentsApp.tsx | 5 +- .../experiments/shared/ExperimentsLayout.tsx | 8 +- 9 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 playground/browser-playground/src/experiments/Experiment10.tsx diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index 7fd33043..184ed3f6 100644 --- a/packages/connect-evm/src/connect.test.ts +++ b/packages/connect-evm/src/connect.test.ts @@ -84,6 +84,7 @@ function createMockCore() { } return Array.from(allScopes); }), + updateSessionScopes: vi.fn().mockResolvedValue(undefined), transport: mockTransport as any, storage: mockStorage as any, diff --git a/packages/connect-evm/src/connect.ts b/packages/connect-evm/src/connect.ts index 19c3a1ea..a1513318 100644 --- a/packages/connect-evm/src/connect.ts +++ b/packages/connect-evm/src/connect.ts @@ -510,9 +510,12 @@ export class MetamaskConnectEVM { logger('Last client disconnecting, revoking session'); await this.#core.disconnect(); } else { + // Other clients remain - update session to only have their scopes + const remainingScopes = this.#core.getUnionScopes(); logger( - `Other clients remain (${this.#core.getClientCount()}), skipping session revocation`, + `Other clients remain (${this.#core.getClientCount()}), updating session to scopes: ${remainingScopes.join(', ')}`, ); + await this.#core.updateSessionScopes(remainingScopes); } this.#onDisconnect(); diff --git a/packages/connect-multichain/src/domain/multichain/index.ts b/packages/connect-multichain/src/domain/multichain/index.ts index 7876f2b2..42739b73 100644 --- a/packages/connect-multichain/src/domain/multichain/index.ts +++ b/packages/connect-multichain/src/domain/multichain/index.ts @@ -118,6 +118,19 @@ export abstract class MultichainCore extends EventEmitter { */ abstract getClientCount(): number; + /** + * Updates the session scopes when a client disconnects but others remain. + * + * NOTE: There is no CAIP standard for partial scope revocation. + * The wallet keeps all previously granted scopes. This method updates + * the SDK's internal tracking only. Full disconnect requires + * wallet_revokeSession when all clients disconnect. + * + * @param scopes - The scopes that remaining clients need + * @returns Promise that resolves when complete + */ + abstract updateSessionScopes(scopes: Scope[]): Promise; + constructor(protected readonly options: MultichainOptions) { super(); } diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index 6aadfb24..ae42a485 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -904,6 +904,45 @@ export class MetaMaskConnectMultichain extends MultichainCore { return this.#activeClients.size; } + /** + * Updates the session scopes when a client disconnects but others remain. + * + * NOTE: There is no CAIP standard for partial scope revocation (CAIP-285 + * only supports full session revocation). This means the wallet will + * keep all previously granted scopes - calling wallet_createSession with + * fewer scopes does not reduce the wallet's session, it's additive only. + * + * This method updates the SDK's internal tracking of scopes but the wallet + * side will retain all previously granted permissions until a full + * disconnect (wallet_revokeSession) occurs. + * + * If no scopes remain, this performs a full disconnect. + * + * @param scopes - The scopes that remaining clients need + * @returns Promise that resolves when complete + */ + async updateSessionScopes(scopes: Scope[]): Promise { + if (this.status !== 'connected' || !this.#transport) { + logger('updateSessionScopes: Not connected or no transport, skipping'); + return; + } + + if (scopes.length === 0) { + // No scopes remaining means we should fully disconnect + logger('updateSessionScopes: No scopes remaining, performing full disconnect'); + await this.disconnect(); + return; + } + + // NOTE: Due to CAIP limitations, we cannot actually reduce wallet scopes. + // The wallet will keep all previously granted permissions. + // We just log and acknowledge - the SDK tracks remaining scopes internally. + logger( + `updateSessionScopes: SDK tracking reduced to [${scopes.join(', ')}], ` + + 'but wallet retains all previously granted scopes (no CAIP partial revocation)', + ); + } + // DRY THIS WITH REQUEST ROUTER openDeeplinkIfNeeded(): void { const { ui, mobile } = this.options; diff --git a/packages/connect-solana/src/connect.test.ts b/packages/connect-solana/src/connect.test.ts index 36678179..edc70019 100644 --- a/packages/connect-solana/src/connect.test.ts +++ b/packages/connect-solana/src/connect.test.ts @@ -49,6 +49,7 @@ describe('createSolanaClient', () => { } return Array.from(allScopes); }), + updateSessionScopes: vi.fn().mockResolvedValue(undefined), }; const mockWallet = { diff --git a/packages/connect-solana/src/connect.ts b/packages/connect-solana/src/connect.ts index b9f81f97..f6baa792 100644 --- a/packages/connect-solana/src/connect.ts +++ b/packages/connect-solana/src/connect.ts @@ -99,6 +99,10 @@ export async function createSolanaClient( // Only actually disconnect if this was the last client if (isLastClient) { await core.disconnect(); + } else { + // Other clients remain - update session to only have their scopes + const remainingScopes = core.getUnionScopes(); + await core.updateSessionScopes(remainingScopes); } }, }; diff --git a/playground/browser-playground/src/experiments/Experiment10.tsx b/playground/browser-playground/src/experiments/Experiment10.tsx new file mode 100644 index 00000000..86d505ef --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment10.tsx @@ -0,0 +1,358 @@ +import { useState, useCallback, useRef } from 'react'; + +import { + hasCachedCore, + getCachedCore, + type MultichainCore, +} from '@metamask/connect-multichain'; +import { + createEVMClient, + type MetamaskConnectEVM, +} from '@metamask/connect-evm'; + +import { ConnectionCard, ActionButton, type ConnectionStatus } from './shared'; + +const DAPP_NAME = 'Experiment 10 - Partial Disconnect'; + +/** + * Experiment 10: Partial Disconnect (Scope Revocation) + * + * This experiment verifies Phase 4: when a client disconnects, only its scopes + * are removed from the session. Other clients remain connected. + * + * Expected behavior: + * 1. Connect Client 1 (Ethereum only) → scopes: [eip155:1] + * 2. Connect Client 2 (Ethereum + Polygon) → scopes: [eip155:1, eip155:137] + * 3. Disconnect Client 2 → scopes should reduce to [eip155:1] (Client 1's scopes) + * 4. Client 1 should still be able to make requests + * 5. Disconnect Client 1 → full session revocation + */ +export function Experiment10() { + const [clientCount, setClientCount] = useState(0); + const [hasCached, setHasCached] = useState(hasCachedCore()); + const [unionScopes, setUnionScopes] = useState([]); + const [logs, setLogs] = useState([]); + const [error, setError] = useState(null); + + // Track per-client state + const [client1Created, setClient1Created] = useState(false); + const [client2Created, setClient2Created] = useState(false); + const [client1Connected, setClient1Connected] = useState(false); + const [client2Connected, setClient2Connected] = useState(false); + const [client1Connecting, setClient1Connecting] = useState(false); + const [client2Connecting, setClient2Connecting] = useState(false); + const [client1Accounts, setClient1Accounts] = useState([]); + const [client2Accounts, setClient2Accounts] = useState([]); + const [client1ChainId, setClient1ChainId] = useState(); + const [client2ChainId, setClient2ChainId] = useState(); + + const evmClient1Ref = useRef(null); + const evmClient2Ref = useRef(null); + + const addLog = useCallback((message: string) => { + setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]); + }, []); + + const refreshState = useCallback(() => { + setHasCached(hasCachedCore()); + const core = getCachedCore() as MultichainCore & { + getClientCount?: () => number; + getUnionScopes?: () => string[]; + } | undefined; + + if (core && typeof core.getClientCount === 'function') { + const count = core.getClientCount(); + setClientCount(count); + + if (typeof core.getUnionScopes === 'function') { + const scopes = core.getUnionScopes(); + setUnionScopes(scopes); + addLog(`Refreshed: ${count} clients, union scopes: [${scopes.join(', ')}]`); + } else { + addLog(`Refreshed: ${count} clients, no getUnionScopes method`); + } + } else { + setClientCount(0); + setUnionScopes([]); + addLog('Refreshed: No core'); + } + }, [addLog]); + + const createClient1 = useCallback(async () => { + try { + setError(null); + addLog('Creating EVM Client 1 (Ethereum only)...'); + const client = await createEVMClient({ + dapp: { name: DAPP_NAME, url: window.location.href }, + api: { supportedNetworks: { '0x1': 'https://mainnet.infura.io/v3/YOUR_KEY' } }, + }); + evmClient1Ref.current = client; + setClient1Created(true); + addLog('EVM Client 1 created'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } + }, [addLog, refreshState]); + + const createClient2 = useCallback(async () => { + try { + setError(null); + addLog('Creating EVM Client 2 (Ethereum + Polygon)...'); + const client = await createEVMClient({ + dapp: { name: DAPP_NAME, url: window.location.href }, + api: { supportedNetworks: { + '0x1': 'https://mainnet.infura.io/v3/YOUR_KEY', + '0x89': 'https://polygon-mainnet.infura.io/v3/YOUR_KEY', + } }, + }); + evmClient2Ref.current = client; + setClient2Created(true); + addLog('EVM Client 2 created'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } + }, [addLog, refreshState]); + + const connectClient1 = useCallback(async () => { + if (!evmClient1Ref.current) return; + try { + setError(null); + setClient1Connecting(true); + addLog('Connecting Client 1 (Ethereum)...'); + const result = await evmClient1Ref.current.connect({ chainIds: ['0x1'] }); + setClient1Connected(true); + setClient1Accounts(result.accounts); + setClient1ChainId(result.chainId); + addLog(`Client 1 connected: chainId=${result.chainId}`); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } finally { + setClient1Connecting(false); + } + }, [addLog, refreshState]); + + const connectClient2 = useCallback(async () => { + if (!evmClient2Ref.current) return; + try { + setError(null); + setClient2Connecting(true); + addLog('Connecting Client 2 (Ethereum + Polygon)...'); + const result = await evmClient2Ref.current.connect({ chainIds: ['0x1', '0x89'] }); + setClient2Connected(true); + setClient2Accounts(result.accounts); + setClient2ChainId(result.chainId); + addLog(`Client 2 connected: chainId=${result.chainId}`); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } finally { + setClient2Connecting(false); + } + }, [addLog, refreshState]); + + const disconnectClient1 = useCallback(async () => { + if (!evmClient1Ref.current) return; + try { + setError(null); + addLog('Disconnecting Client 1...'); + addLog('Expected: If Client 2 is connected, session scopes should reduce to Client 2\'s scopes'); + await evmClient1Ref.current.disconnect(); + setClient1Connected(false); + setClient1Accounts([]); + setClient1ChainId(undefined); + addLog('Client 1 disconnected'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } + }, [addLog, refreshState]); + + const disconnectClient2 = useCallback(async () => { + if (!evmClient2Ref.current) return; + try { + setError(null); + addLog('Disconnecting Client 2...'); + addLog('Expected: If Client 1 is connected, session scopes should reduce to [eip155:1]'); + await evmClient2Ref.current.disconnect(); + setClient2Connected(false); + setClient2Accounts([]); + setClient2ChainId(undefined); + addLog('Client 2 disconnected'); + refreshState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Error: ${msg}`); + } + }, [addLog, refreshState]); + + const testClient1Request = useCallback(async () => { + if (!evmClient1Ref.current) { + addLog('ERROR: Client 1 not created'); + return; + } + try { + setError(null); + addLog('Testing Client 1 request (eth_accounts)...'); + const accounts = await evmClient1Ref.current.getProvider().request({ method: 'eth_accounts' }); + addLog(`Client 1 eth_accounts result: ${JSON.stringify(accounts)}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + addLog(`Client 1 request FAILED: ${msg}`); + } + }, [addLog]); + + const clearLogs = useCallback(() => setLogs([]), []); + + const getStatus = (created: boolean, connected: boolean, connecting: boolean): ConnectionStatus => { + if (!created) return 'disconnected'; + if (connecting) return 'connecting'; + if (connected) return 'connected'; + return 'disconnected'; + }; + + return ( +
+

Experiment 10: Partial Disconnect (Scope Revocation)

+

+ Verifies that when a client disconnects, only its scopes are removed. + Other clients remain connected with their scopes. +

+ + {/* State Display */} +
+

Core State

+ + + + + + + + + + + + + + + +
Has Cached Core:{hasCached ? '✅ Yes' : '❌ No'}
Registered Clients:{clientCount}
Union Scopes: + {unionScopes.length > 0 ? `[${unionScopes.join(', ')}]` : '[]'} +
+
+ Refresh State +
+
+ + {/* Expected Behavior */} +
+

Test Scenario

+
    +
  1. Connect Client 1 (Ethereum) → scopes: [eip155:1]
  2. +
  3. Connect Client 2 (Ethereum + Polygon) → scopes: [eip155:1, eip155:137]
  4. +
  5. Disconnect Client 2 → scopes should reduce to [eip155:1]
  6. +
  7. Test Client 1 Request → Should still work
  8. +
  9. Disconnect Client 1 → Full revocation, 0 clients
  10. +
+
+ + {/* Client Cards */} +
+ +
+ {!client1Created && ( + Create Client 1 + )} + {client1Created && !client1Connected && !client1Connecting && ( + Connect + )} + {client1Connecting && ( +
+ + + + + Waiting... +
+ )} + {client1Created && client1Connected && ( + <> + Test Request + Disconnect + + )} +
+
+ + +
+ {!client2Created && ( + Create Client 2 + )} + {client2Created && !client2Connected && !client2Connecting && ( + Connect + )} + {client2Connecting && ( +
+ + + + + Waiting... +
+ )} + {client2Created && client2Connected && ( + Disconnect + )} +
+
+
+ + {/* Logs */} +
+
+ Activity Log + +
+ {logs.length === 0 ? ( +
No activity yet...
+ ) : ( + logs.map((log, i) =>
{log}
) + )} +
+ + {error && ( +
+ Error: {error} +
+ )} +
+ ); +} diff --git a/playground/browser-playground/src/experiments/ExperimentsApp.tsx b/playground/browser-playground/src/experiments/ExperimentsApp.tsx index 0e554ffa..73b2d27c 100644 --- a/playground/browser-playground/src/experiments/ExperimentsApp.tsx +++ b/playground/browser-playground/src/experiments/ExperimentsApp.tsx @@ -9,11 +9,12 @@ import { Experiment6 } from './Experiment6'; import { Experiment7 } from './Experiment7'; import { Experiment8 } from './Experiment8'; import { Experiment9 } from './Experiment9'; +import { Experiment10 } from './Experiment10'; // Get experiment from URL hash, default to exp1 function getExperimentFromHash(): ExperimentId { const hash = window.location.hash.slice(1); - if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7', 'exp8', 'exp9'].includes(hash)) { + if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7', 'exp8', 'exp9', 'exp10'].includes(hash)) { return hash as ExperimentId; } return 'exp1'; @@ -65,6 +66,8 @@ export function ExperimentsApp() { return ; case 'exp9': return ; + case 'exp10': + return ; default: return ; } diff --git a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx index b8028b76..aa6f0b56 100644 --- a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx +++ b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx @@ -10,7 +10,8 @@ export type ExperimentId = | 'exp6' | 'exp7' | 'exp8' - | 'exp9'; + | 'exp9' + | 'exp10'; type ExperimentInfo = { id: ExperimentId; @@ -64,6 +65,11 @@ export const EXPERIMENTS: ExperimentInfo[] = [ title: 'Exp 9: Scope Merging', description: 'Verify scopes are merged when connecting clients with different chains', }, + { + id: 'exp10', + title: 'Exp 10: Partial Disconnect', + description: 'Verify scopes are partially revoked when one client disconnects', + }, ]; type ExperimentsLayoutProps = {