diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index 7e0c7e90..184ed3f6 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 (with scopes) + 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,26 @@ function createMockCore() { disconnect: vi.fn().mockResolvedValue(undefined), + // 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); + }), + updateSessionScopes: vi.fn().mockResolvedValue(undefined), + 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..a1513318 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 and scope tracking) + if (!this.#isRegistered) { + this.#core.registerClient(this.#clientId, 'evm', caipChainIds as Scope[]); + this.#isRegistered = true; + } + const hexPermittedChainIds = getPermittedEthChainIds(this.#sessionScopes); const initialAccounts = await this.#core.transport.sendEip1193Message< @@ -479,13 +492,32 @@ 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 { + // Other clients remain - update session to only have their scopes + const remainingScopes = this.#core.getUnionScopes(); + logger( + `Other clients remain (${this.#core.getClientCount()}), updating session to scopes: ${remainingScopes.join(', ')}`, + ); + await this.#core.updateSessionScopes(remainingScopes); + } + 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..42739b73 100644 --- a/packages/connect-multichain/src/domain/multichain/index.ts +++ b/packages/connect-multichain/src/domain/multichain/index.ts @@ -24,6 +24,20 @@ 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; + /** The scopes this client has requested */ + scopes: Scope[]; +}; + /** * Abstract base class for the Multichain SDK implementation. * @@ -70,6 +84,53 @@ 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') + * @param scopes - The scopes this client has requested + */ + 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. + * 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; + + /** + * 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/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/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index 83fa122a..ae42a485 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,104 @@ 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') + * @param scopes - The scopes this client has requested + */ + 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. + * 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; + } + + /** + * 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-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-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/packages/connect-solana/src/connect.test.ts b/packages/connect-solana/src/connect.test.ts index a72a343e..edc70019 100644 --- a/packages/connect-solana/src/connect.test.ts +++ b/packages/connect-solana/src/connect.test.ts @@ -25,9 +25,31 @@ describe('createSolanaClient', () => { debug: true, }; + // 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 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); + }), + updateSessionScopes: vi.fn().mockResolvedValue(undefined), }; const mockWallet = { @@ -37,6 +59,7 @@ describe('createSolanaClient', () => { beforeEach(() => { vi.clearAllMocks(); + registeredClients.clear(); (createMultichainClient as ReturnType).mockResolvedValue( mockCore, ); @@ -154,13 +177,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', scopes: ['eip155:1'] }); + + // 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..f6baa792 100644 --- a/packages/connect-solana/src/connect.ts +++ b/packages/connect-solana/src/connect.ts @@ -69,12 +69,41 @@ 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; + + // 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, 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', solanaScopes); + 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(); + } 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/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/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/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/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/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/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 32764bc6..73b2d27c 100644 --- a/playground/browser-playground/src/experiments/ExperimentsApp.tsx +++ b/playground/browser-playground/src/experiments/ExperimentsApp.tsx @@ -6,11 +6,15 @@ import { Experiment3 } from './Experiment3'; import { Experiment4 } from './Experiment4'; import { Experiment5 } from './Experiment5'; 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'].includes(hash)) { + if (['exp1', 'exp2', 'exp3', 'exp4', 'exp5', 'exp6', 'exp7', 'exp8', 'exp9', 'exp10'].includes(hash)) { return hash as ExperimentId; } return 'exp1'; @@ -56,6 +60,14 @@ export function ExperimentsApp() { return ; case 'exp6': return ; + case 'exp7': + return ; + case 'exp8': + 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 79671d4e..aa6f0b56 100644 --- a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx +++ b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx @@ -7,7 +7,11 @@ export type ExperimentId = | 'exp3' | 'exp4' | 'exp5' - | 'exp6'; + | 'exp6' + | 'exp7' + | 'exp8' + | 'exp9' + | 'exp10'; type ExperimentInfo = { id: ExperimentId; @@ -46,6 +50,26 @@ 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', + }, + { + id: 'exp8', + 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', + }, + { + id: 'exp10', + title: 'Exp 10: Partial Disconnect', + description: 'Verify scopes are partially revoked when one client disconnects', + }, ]; type ExperimentsLayoutProps = {