diff --git a/.gitignore b/.gitignore index fdff3b53..6924915b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ playground/*/*.tsbuildinfo # env files **/.env + +# Local investigation/POC folder +temp/ diff --git a/packages/connect-evm/CHANGELOG.md b/packages/connect-evm/CHANGELOG.md index 82cf2d3d..b7e35d6d 100644 --- a/packages/connect-evm/CHANGELOG.md +++ b/packages/connect-evm/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - update `connect()` and `createEVMClient()` typings to be more accurate ([#153](https://github.com/MetaMask/connect-monorepo/pull/153)) - update `switchChain()` to return `Promise` ([#153](https://github.com/MetaMask/connect-monorepo/pull/153)) +### Fixed + +- Fix `display_uri` and `wallet_sessionChanged` events not firing on reconnect after disconnect in headless mode ([#TBD](https://github.com/MetaMask/connect-monorepo/pull/TBD)) + ## [0.4.1] ### Fixed diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index 34a74163..7e0c7e90 100644 --- a/packages/connect-evm/src/connect.test.ts +++ b/packages/connect-evm/src/connect.test.ts @@ -1,8 +1,218 @@ /* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ -import { describe, it, expect } from 'vitest'; +/* eslint-disable @typescript-eslint/no-explicit-any -- Test mocks */ +/* eslint-disable jsdoc/require-jsdoc -- Test file */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; -describe('smoke', () => { - it('works', () => { - expect(true).toBe(true); +import { MetamaskConnectEVM } from './connect'; +import type { MultichainCore, SessionData } from '@metamask/connect-multichain'; +import { EventEmitter } from '@metamask/connect-multichain'; + +/** + * Creates a mock MultichainCore for testing. + * The mock tracks event listener registration and can emit events. + */ +function createMockCore() { + // Use a real EventEmitter to track listeners properly + const emitter = new EventEmitter(); + + const mockTransport = { + sendEip1193Message: vi.fn().mockResolvedValue({ result: ['0x1234'] }), + onNotification: vi.fn().mockReturnValue(() => { }), + request: vi.fn().mockResolvedValue({ result: {} }), + }; + + const mockStorage = { + adapter: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }, + }; + + const mockCore: Partial = { + // Delegate event methods to the real emitter + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + return emitter.on(event as any, handler as any); + }), + off: vi.fn((event: string, handler: (...args: any[]) => void) => { + emitter.off(event as any, handler as any); + }), + emit: vi.fn((event: string, ...args: any[]) => { + emitter.emit(event as any, ...args); + }), + listenerCount: vi.fn((event: string) => { + return emitter.listenerCount(event as any); + }), + + connect: vi.fn().mockImplementation(async function (this: any) { + // Simulate emitting display_uri during connection + // This is what happens in headless mode when QR code is generated + emitter.emit('display_uri' as any, 'metamask://connect?id=test-session'); + + // Simulate session update + const mockSession: SessionData = { + sessionScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + methods: ['eth_sendTransaction'], + notifications: [], + }, + }, + }; + emitter.emit('wallet_sessionChanged' as any, mockSession); + }), + + disconnect: vi.fn().mockResolvedValue(undefined), + + transport: mockTransport as any, + storage: mockStorage as any, + status: 'connected' as const, + openDeeplinkIfNeeded: vi.fn(), + }; + + return { + core: mockCore as MultichainCore, + emitter, + mockTransport, + }; +} + +describe('MetamaskConnectEVM', () => { + describe('event listener lifecycle', () => { + let mockCore: MultichainCore; + let emitter: EventEmitter; + + beforeEach(() => { + const mocks = createMockCore(); + mockCore = mocks.core; + emitter = mocks.emitter; + }); + + it('should emit display_uri on first connect', async () => { + const displayUriHandler = vi.fn(); + + const sdk = await MetamaskConnectEVM.create({ + core: mockCore, + eventHandlers: { + displayUri: displayUriHandler, + }, + }); + + // First connect + await sdk.connect({ chainIds: ['0x1'] }); + + expect(displayUriHandler).toHaveBeenCalledTimes(1); + expect(displayUriHandler).toHaveBeenCalledWith( + 'metamask://connect?id=test-session', + ); + }); + + it('should emit display_uri on reconnect after disconnect', async () => { + const displayUriHandler = vi.fn(); + + const sdk = await MetamaskConnectEVM.create({ + core: mockCore, + eventHandlers: { + displayUri: displayUriHandler, + }, + }); + + // First connect - should work + await sdk.connect({ chainIds: ['0x1'] }); + expect(displayUriHandler).toHaveBeenCalledTimes(1); + + // Clear mock to track next call + displayUriHandler.mockClear(); + + // Disconnect + await sdk.disconnect(); + + // Second connect - THIS IS THE BUG: display_uri won't fire if listeners were removed + await sdk.connect({ chainIds: ['0x1'] }); + + // This assertion will FAIL before the fix is applied + expect(displayUriHandler).toHaveBeenCalledTimes(1); + expect(displayUriHandler).toHaveBeenCalledWith( + 'metamask://connect?id=test-session', + ); + }); + + it('should emit wallet_sessionChanged on reconnect after disconnect', async () => { + let sessionData: SessionData | undefined; + + const sdk = await MetamaskConnectEVM.create({ + core: mockCore, + eventHandlers: {}, + }); + + // Listen for session changes via the core's event + mockCore.on('wallet_sessionChanged', (session) => { + sessionData = session as SessionData; + }); + + // First connect + await sdk.connect({ chainIds: ['0x1'] }); + expect(sessionData).toBeDefined(); + expect(sessionData?.sessionScopes['eip155:1']).toBeDefined(); + + // Reset + sessionData = undefined; + + // Disconnect + await sdk.disconnect(); + + // Second connect - should still receive session changes + await sdk.connect({ chainIds: ['0x1'] }); + + // This should still work even after disconnect + expect(sessionData).toBeDefined(); + }); + + it('should handle multiple connect/disconnect cycles', async () => { + const displayUriHandler = vi.fn(); + + const sdk = await MetamaskConnectEVM.create({ + core: mockCore, + eventHandlers: { + displayUri: displayUriHandler, + }, + }); + + // Cycle 1 + await sdk.connect({ chainIds: ['0x1'] }); + expect(displayUriHandler).toHaveBeenCalledTimes(1); + await sdk.disconnect(); + + // Cycle 2 + await sdk.connect({ chainIds: ['0x1'] }); + expect(displayUriHandler).toHaveBeenCalledTimes(2); + await sdk.disconnect(); + + // Cycle 3 + await sdk.connect({ chainIds: ['0x1'] }); + expect(displayUriHandler).toHaveBeenCalledTimes(3); + }); + + it('should not register duplicate listeners on multiple connects without disconnect', async () => { + const displayUriHandler = vi.fn(); + + const sdk = await MetamaskConnectEVM.create({ + core: mockCore, + eventHandlers: { + displayUri: displayUriHandler, + }, + }); + + // Connect multiple times without disconnecting + await sdk.connect({ chainIds: ['0x1'] }); + await sdk.connect({ chainIds: ['0x1'] }); + await sdk.connect({ chainIds: ['0x1'] }); + + // Should be called 3 times (once per connect), not more + // If duplicates were registered, it would be more than 3 + expect(displayUriHandler).toHaveBeenCalledTimes(3); + + // Check listener count - should be exactly 1 + expect(emitter.listenerCount('display_uri')).toBe(1); + }); }); }); diff --git a/packages/connect-evm/src/connect.ts b/packages/connect-evm/src/connect.ts index f8a7ddfa..098e4988 100644 --- a/packages/connect-evm/src/connect.ts +++ b/packages/connect-evm/src/connect.ts @@ -489,8 +489,10 @@ export class MetamaskConnectEVM { this.#onDisconnect(); this.#clearConnectionState(); - this.#core.off('wallet_sessionChanged', this.#sessionChangedHandler); - this.#core.off('display_uri', this.#displayUriHandler); + // Note: We intentionally do NOT remove the display_uri and wallet_sessionChanged + // listeners here. These are instance-scoped listeners that should remain active + // for the lifetime of the SDK instance, allowing reconnection to work properly. + // Session-scoped listeners (like the notification handler below) are removed. if (this.#removeNotificationHandler) { this.#removeNotificationHandler(); @@ -1008,6 +1010,7 @@ export async function createEVMClient( try { const core = await createMultichainClient({ ...options, + sdkType: 'evm', api: { supportedNetworks: supportedNetworksCaipChainId, }, diff --git a/packages/connect-multichain/src/connect.test.ts b/packages/connect-multichain/src/connect.test.ts index 8cbeec90..795ab3ed 100644 --- a/packages/connect-multichain/src/connect.test.ts +++ b/packages/connect-multichain/src/connect.test.ts @@ -108,10 +108,10 @@ function testSuite({ const uiOptions: MultichainOptions['ui'] = platform === 'web-mobile' ? { - ...originalSdkOptions.ui, - showInstallModal: false, - preferExtension: false, - } + ...originalSdkOptions.ui, + showInstallModal: false, + preferExtension: false, + } : originalSdkOptions.ui; mockedData = await beforeEach(); @@ -593,8 +593,10 @@ function testSuite({ const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; +// instanceId: '' disables storage key prefixing for backwards-compatible test behavior const baseTestOptions = { dapp: exampleDapp, + instanceId: '', } as any; runTestsInNodeEnv(baseTestOptions, testSuite); diff --git a/packages/connect-multichain/src/domain/multichain/types.ts b/packages/connect-multichain/src/domain/multichain/types.ts index 63086a8a..a198f0c0 100644 --- a/packages/connect-multichain/src/domain/multichain/types.ts +++ b/packages/connect-multichain/src/domain/multichain/types.ts @@ -81,6 +81,26 @@ export type MultichainOptions = { }; /** Enable debug logging */ debug?: boolean; + /** + * Optional instance identifier for storage isolation. + * When provided, all storage keys will be prefixed with this ID, + * enabling multiple SDK instances to coexist without interference. + * + * If not provided, a deterministic ID based on dapp.name and SDK type + * will be generated to ensure multi-tab consistency while maintaining + * isolation between different SDK types (multichain, evm, solana, etc.) + */ + instanceId?: string; + + /** + * The SDK type identifier used for generating the instanceId suffix. + * This ensures different SDK types (multichain, evm, solana) are isolated + * even when using the same dapp.name. + * + * @default 'multichain' + * @example 'evm' for connect-evm, 'solana' for connect-solana + */ + sdkType?: string; }; type MultiChainFNOptions = Omit & { diff --git a/packages/connect-multichain/src/index.browser.ts b/packages/connect-multichain/src/index.browser.ts index a18b7a5d..4cfcdf88 100644 --- a/packages/connect-multichain/src/index.browser.ts +++ b/packages/connect-multichain/src/index.browser.ts @@ -2,10 +2,13 @@ // Buffer polyfill must be imported first to set up globalThis.Buffer import './polyfills/buffer-shim'; -import type { CreateMultichainFN, StoreClient } from './domain'; +import type { CreateMultichainFN } from './domain'; import { enableDebug } from './domain'; import { MetaMaskConnectMultichain } from './multichain'; -import { Store } from './store'; +import { + createIsolatedStorage, + generateInstanceId, +} from './store/create-storage'; import { ModalFactory } from './ui'; export * from './domain'; @@ -16,14 +19,22 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { } const uiModules = await import('./ui/modals/web'); - let storage: StoreClient; - if (options.storage) { - storage = options.storage; - } else { - const { StoreAdapterWeb } = await import('./store/adapters/web'); - const adapter = new StoreAdapterWeb(); - storage = new Store(adapter); - } + + // 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); + + const storage = await createIsolatedStorage({ + instanceId, + userStorage: options.storage, + createAdapter: async () => { + const { StoreAdapterWeb } = await import('./store/adapters/web'); + return new StoreAdapterWeb(); + }, + }); + const factory = new ModalFactory(uiModules); return MetaMaskConnectMultichain.create({ ...options, diff --git a/packages/connect-multichain/src/index.native.ts b/packages/connect-multichain/src/index.native.ts index 2635133c..ed81af2c 100644 --- a/packages/connect-multichain/src/index.native.ts +++ b/packages/connect-multichain/src/index.native.ts @@ -2,10 +2,13 @@ // Buffer polyfill must be imported first to set up global.Buffer import './polyfills/buffer-shim'; -import type { CreateMultichainFN, StoreClient } from './domain'; +import type { CreateMultichainFN } from './domain'; import { enableDebug } from './domain'; import { MetaMaskConnectMultichain } from './multichain'; -import { Store } from './store'; +import { + createIsolatedStorage, + generateInstanceId, +} from './store/create-storage'; import { ModalFactory } from './ui/index.native'; export * from './domain'; @@ -16,14 +19,22 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { } const uiModules = await import('./ui/modals/rn'); - let storage: StoreClient; - if (options.storage) { - storage = options.storage; - } else { - const { StoreAdapterRN } = await import('./store/adapters/rn'); - const adapter = new StoreAdapterRN(); - storage = new Store(adapter); - } + + // 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); + + const storage = await createIsolatedStorage({ + instanceId, + userStorage: options.storage, + createAdapter: async () => { + const { StoreAdapterRN } = await import('./store/adapters/rn'); + return new StoreAdapterRN(); + }, + }); + const factory = new ModalFactory(uiModules); return MetaMaskConnectMultichain.create({ ...options, diff --git a/packages/connect-multichain/src/index.node.ts b/packages/connect-multichain/src/index.node.ts index 8c929f1b..c8bac3aa 100644 --- a/packages/connect-multichain/src/index.node.ts +++ b/packages/connect-multichain/src/index.node.ts @@ -1,7 +1,10 @@ -import type { CreateMultichainFN, StoreClient } from './domain'; +import type { CreateMultichainFN } from './domain'; import { enableDebug } from './domain'; import { MetaMaskConnectMultichain } from './multichain'; -import { Store } from './store'; +import { + createIsolatedStorage, + generateInstanceId, +} from './store/create-storage'; import { ModalFactory } from './ui'; export * from './domain'; @@ -12,14 +15,22 @@ export const createMultichainClient: CreateMultichainFN = async (options) => { } const uiModules = await import('./ui/modals/node'); - let storage: StoreClient; - if (options.storage) { - storage = options.storage; - } else { - const { StoreAdapterNode } = await import('./store/adapters/node'); - const adapter = new StoreAdapterNode(); - storage = new Store(adapter); - } + + // 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); + + const storage = await createIsolatedStorage({ + instanceId, + userStorage: options.storage, + createAdapter: async () => { + const { StoreAdapterNode } = await import('./store/adapters/node'); + return new StoreAdapterNode(); + }, + }); + const factory = new ModalFactory(uiModules); return MetaMaskConnectMultichain.create({ ...options, diff --git a/packages/connect-multichain/src/init.test.ts b/packages/connect-multichain/src/init.test.ts index 55b30c0a..a9af84b9 100644 --- a/packages/connect-multichain/src/init.test.ts +++ b/packages/connect-multichain/src/init.test.ts @@ -47,10 +47,10 @@ function testSuite({ const uiOptions: MultichainOptions['ui'] = platform === 'web-mobile' ? { - ...originalSdkOptions.ui, - showInstallModal: false, - preferExtension: false, - } + ...originalSdkOptions.ui, + showInstallModal: false, + preferExtension: false, + } : originalSdkOptions.ui; mockedData = await beforeEach(); @@ -293,7 +293,8 @@ function testSuite({ const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; -const baseTestOptions = { dapp: exampleDapp } as any; +// instanceId: '' disables storage key prefixing for backwards-compatible test behavior +const baseTestOptions = { dapp: exampleDapp, instanceId: '' } as any; runTestsInNodeEnv(baseTestOptions, testSuite); runTestsInRNEnv(baseTestOptions, testSuite); diff --git a/packages/connect-multichain/src/invoke.test.ts b/packages/connect-multichain/src/invoke.test.ts index 506e6770..89d41f33 100644 --- a/packages/connect-multichain/src/invoke.test.ts +++ b/packages/connect-multichain/src/invoke.test.ts @@ -106,10 +106,10 @@ function testSuite({ const uiOptions: MultichainOptions['ui'] = platform === 'web-mobile' ? { - ...originalSdkOptions.ui, - showInstallModal: false, - preferExtension: false, - } + ...originalSdkOptions.ui, + showInstallModal: false, + preferExtension: false, + } : originalSdkOptions.ui; mockedData = await beforeEach(); // Set the transport type as a string in storage (this is how it's stored) @@ -358,7 +358,8 @@ function testSuite({ const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; -const baseTestOptions = { dapp: exampleDapp } as any; +// instanceId: '' disables storage key prefixing for backwards-compatible test behavior +const baseTestOptions = { dapp: exampleDapp, instanceId: '' } as any; runTestsInNodeEnv(baseTestOptions, testSuite); runTestsInRNEnv(baseTestOptions, testSuite); diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index a90e2f1d..83fa122a 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -329,21 +329,20 @@ export class MetaMaskConnectMultichain extends MultichainCore { } #createBeforeUnloadListener(): () => void { + const handler = this.#onBeforeUnload.bind(this); + if ( typeof window !== 'undefined' && typeof window.addEventListener !== 'undefined' ) { - window.addEventListener('beforeunload', this.#onBeforeUnload.bind(this)); + window.addEventListener('beforeunload', handler); } return () => { if ( typeof window !== 'undefined' && typeof window.removeEventListener !== 'undefined' ) { - window.removeEventListener( - 'beforeunload', - this.#onBeforeUnload.bind(this), - ); + window.removeEventListener('beforeunload', handler); } }; } diff --git a/packages/connect-multichain/src/session.test.ts b/packages/connect-multichain/src/session.test.ts index 70799385..033232ae 100644 --- a/packages/connect-multichain/src/session.test.ts +++ b/packages/connect-multichain/src/session.test.ts @@ -53,10 +53,10 @@ function testSuite({ const uiOptions: MultichainOptions['ui'] = platform === 'web-mobile' ? { - ...originalSdkOptions.ui, - showInstallModal: false, - preferExtension: false, - } + ...originalSdkOptions.ui, + showInstallModal: false, + preferExtension: false, + } : originalSdkOptions.ui; mockedData = await beforeEach(); @@ -374,7 +374,8 @@ function testSuite({ const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; -const baseTestOptions = { dapp: exampleDapp } as any; +// instanceId: '' disables storage key prefixing for backwards-compatible test behavior +const baseTestOptions = { dapp: exampleDapp, instanceId: '' } as any; runTestsInNodeEnv(baseTestOptions, testSuite); runTestsInRNEnv(baseTestOptions, testSuite); diff --git a/packages/connect-multichain/src/store/adapters/prefixed.test.ts b/packages/connect-multichain/src/store/adapters/prefixed.test.ts new file mode 100644 index 00000000..9713af87 --- /dev/null +++ b/packages/connect-multichain/src/store/adapters/prefixed.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { StoreAdapter } from '../../domain'; + +import { PrefixedStoreAdapter } from './prefixed'; + +describe('PrefixedStoreAdapter', () => { + let mockInnerAdapter: StoreAdapter; + + beforeEach(() => { + mockInnerAdapter = { + platform: 'web', + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + } as unknown as StoreAdapter; + }); + + describe('constructor', () => { + it('should inherit platform from inner adapter', () => { + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, 'test:'); + expect(prefixed.platform).toBe('web'); + }); + + it('should work with different platforms', () => { + const rnAdapter: StoreAdapter = { + ...mockInnerAdapter, + platform: 'rn', + } as StoreAdapter; + const prefixed = new PrefixedStoreAdapter(rnAdapter, 'test:'); + expect(prefixed.platform).toBe('rn'); + }); + }); + + describe('get', () => { + it('should prefix the key when calling inner adapter', async () => { + vi.mocked(mockInnerAdapter.get).mockResolvedValue('value'); + + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, 'myapp:'); + const result = await prefixed.get('session'); + + expect(mockInnerAdapter.get).toHaveBeenCalledWith('myapp:session'); + expect(result).toBe('value'); + }); + + it('should return null when inner adapter returns null', async () => { + vi.mocked(mockInnerAdapter.get).mockResolvedValue(null); + + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, 'myapp:'); + const result = await prefixed.get('nonexistent'); + + expect(result).toBeNull(); + }); + + it('should handle empty prefix', async () => { + vi.mocked(mockInnerAdapter.get).mockResolvedValue('value'); + + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, ''); + await prefixed.get('key'); + + expect(mockInnerAdapter.get).toHaveBeenCalledWith('key'); + }); + + it('should handle complex key names', async () => { + vi.mocked(mockInnerAdapter.get).mockResolvedValue('value'); + + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, 'app:'); + await prefixed.get('session:abc123:nonce'); + + expect(mockInnerAdapter.get).toHaveBeenCalledWith( + 'app:session:abc123:nonce', + ); + }); + }); + + describe('set', () => { + it('should prefix the key when calling inner adapter', async () => { + vi.mocked(mockInnerAdapter.set).mockResolvedValue(undefined); + + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, 'myapp:'); + await prefixed.set('transport', 'mwp'); + + expect(mockInnerAdapter.set).toHaveBeenCalledWith('myapp:transport', 'mwp'); + }); + + it('should handle JSON values', async () => { + vi.mocked(mockInnerAdapter.set).mockResolvedValue(undefined); + + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, 'test:'); + const jsonValue = JSON.stringify({ accounts: ['0x123'] }); + await prefixed.set('cache', jsonValue); + + expect(mockInnerAdapter.set).toHaveBeenCalledWith('test:cache', jsonValue); + }); + }); + + describe('delete', () => { + it('should prefix the key when calling inner adapter', async () => { + vi.mocked(mockInnerAdapter.delete).mockResolvedValue(undefined); + + const prefixed = new PrefixedStoreAdapter(mockInnerAdapter, 'myapp:'); + await prefixed.delete('session'); + + expect(mockInnerAdapter.delete).toHaveBeenCalledWith('myapp:session'); + }); + }); + + describe('isolation behavior', () => { + it('should allow multiple instances with different prefixes to coexist', async () => { + const storage = new Map(); + const sharedAdapter: 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; + + const clientA = new PrefixedStoreAdapter(sharedAdapter, 'client-a:'); + const clientB = new PrefixedStoreAdapter(sharedAdapter, 'client-b:'); + + // Both clients set the same key name + await clientA.set('transport', 'browser'); + await clientB.set('transport', 'mwp'); + + // They should have different values + const resultA = await clientA.get('transport'); + const resultB = await clientB.get('transport'); + + expect(resultA).toBe('browser'); + expect(resultB).toBe('mwp'); + + // Underlying storage should have both prefixed keys + expect(storage.get('client-a:transport')).toBe('browser'); + expect(storage.get('client-b:transport')).toBe('mwp'); + }); + + it('should not affect other clients when deleting', async () => { + const storage = new Map(); + const sharedAdapter: 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; + + const clientA = new PrefixedStoreAdapter(sharedAdapter, 'client-a:'); + const clientB = new PrefixedStoreAdapter(sharedAdapter, 'client-b:'); + + await clientA.set('session', 'session-a'); + await clientB.set('session', 'session-b'); + + // Delete from client A + await clientA.delete('session'); + + // Client A should see null, client B should still have value + expect(await clientA.get('session')).toBeNull(); + expect(await clientB.get('session')).toBe('session-b'); + }); + }); +}); diff --git a/packages/connect-multichain/src/store/adapters/prefixed.ts b/packages/connect-multichain/src/store/adapters/prefixed.ts new file mode 100644 index 00000000..1faaca40 --- /dev/null +++ b/packages/connect-multichain/src/store/adapters/prefixed.ts @@ -0,0 +1,43 @@ +import { StoreAdapter } from '../../domain'; + +/** + * A wrapper adapter that prefixes all storage keys with a given namespace. + * This enables isolation between different SDK instances by ensuring + * each instance uses its own namespace for storage operations. + * + * @example + * ```typescript + * const rawAdapter = new StoreAdapterWeb(); + * const prefixedAdapter = new PrefixedStoreAdapter(rawAdapter, 'myapp-evm:'); + * // All keys will be prefixed: get('foo') -> get('myapp-evm:foo') + * ``` + */ +export class PrefixedStoreAdapter extends StoreAdapter { + readonly platform: 'web' | 'rn' | 'node'; + + /** + * Creates a new PrefixedStoreAdapter. + * + * @param inner - The underlying storage adapter to wrap + * @param prefix - The prefix to prepend to all storage keys + */ + constructor( + private readonly inner: StoreAdapter, + private readonly prefix: string, + ) { + super(); + this.platform = inner.platform; + } + + async get(key: string): Promise { + return this.inner.get(`${this.prefix}${key}`); + } + + async set(key: string, value: string): Promise { + return this.inner.set(`${this.prefix}${key}`, value); + } + + async delete(key: string): Promise { + return this.inner.delete(`${this.prefix}${key}`); + } +} diff --git a/packages/connect-multichain/src/store/adapters/web.ts b/packages/connect-multichain/src/store/adapters/web.ts index 42ed869e..1acca691 100644 --- a/packages/connect-multichain/src/store/adapters/web.ts +++ b/packages/connect-multichain/src/store/adapters/web.ts @@ -31,9 +31,12 @@ export class StoreAdapterWeb extends StoreAdapter { super(); const dbName = `${StoreAdapterWeb.DB_NAME}${dbNameSuffix}`; + // Version 2: Added 'sdk-kv-store' and 'key-value-pairs' object stores + // (version 1 may have had different stores in older codebase versions) + const dbVersion = 2; this.dbPromise = new Promise((resolve, reject) => { try { - const request = this.internal.open(dbName, 1); + const request = this.internal.open(dbName, dbVersion); request.onerror = () => reject(new Error('Failed to open IndexedDB.')); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = () => { diff --git a/packages/connect-multichain/src/store/create-storage.test.ts b/packages/connect-multichain/src/store/create-storage.test.ts new file mode 100644 index 00000000..41d53cd9 --- /dev/null +++ b/packages/connect-multichain/src/store/create-storage.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi } from 'vitest'; + +import type { StoreAdapter, StoreClient } from '../domain'; + +import { generateInstanceId, createIsolatedStorage } from './create-storage'; +import { PrefixedStoreAdapter } from './adapters/prefixed'; +import { Store } from './index'; + +describe('generateInstanceId', () => { + it('should create deterministic ID from dapp name and SDK type', () => { + const id = generateInstanceId('My DApp', 'multichain'); + expect(id).toBe('my-dapp-multichain'); + }); + + it('should lowercase the dapp name', () => { + const id = generateInstanceId('MyDApp', 'evm'); + expect(id).toBe('mydapp-evm'); + }); + + it('should replace non-alphanumeric characters with dashes', () => { + const id = generateInstanceId('My App (v2.0)', 'multichain'); + expect(id).toBe('my-app--v2-0--multichain'); + }); + + it('should handle special characters', () => { + const id = generateInstanceId('DApp@123!', 'solana'); + expect(id).toBe('dapp-123--solana'); + }); + + it('should produce same ID for same inputs', () => { + const id1 = generateInstanceId('TestApp', 'evm'); + const id2 = generateInstanceId('TestApp', 'evm'); + expect(id1).toBe(id2); + }); + + it('should produce different IDs for different SDK types', () => { + const multichain = generateInstanceId('MyApp', 'multichain'); + const evm = generateInstanceId('MyApp', 'evm'); + expect(multichain).not.toBe(evm); + expect(multichain).toBe('myapp-multichain'); + expect(evm).toBe('myapp-evm'); + }); + + it('should handle empty dapp name', () => { + const id = generateInstanceId('', 'multichain'); + expect(id).toBe('-multichain'); + }); + + it('should handle unicode characters', () => { + const id = generateInstanceId('DApp日本語', 'evm'); + // Non-alphanumeric (including unicode) replaced with dashes (3 chars = 3 dashes) + expect(id).toBe('dapp----evm'); + }); +}); + +describe('createIsolatedStorage', () => { + const createMockAdapter = (): StoreAdapter => + ({ + platform: 'web', + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }) as unknown as StoreAdapter; + + const createMockStorage = (adapter: StoreAdapter): StoreClient => + ({ + adapter, + getTransport: vi.fn(), + setTransport: vi.fn(), + }) as unknown as StoreClient; + + describe('with instanceId', () => { + it('should wrap user-provided storage with prefix', async () => { + const mockAdapter = createMockAdapter(); + const userStorage = createMockStorage(mockAdapter); + + const storage = await createIsolatedStorage({ + instanceId: 'myapp-evm', + userStorage, + createAdapter: async () => createMockAdapter(), + }); + + // Should be a new Store instance + expect(storage).toBeInstanceOf(Store); + + // The adapter should be prefixed + expect(storage.adapter).toBeInstanceOf(PrefixedStoreAdapter); + + // Verify prefixing works + await storage.adapter.set('key', 'value'); + expect(mockAdapter.set).toHaveBeenCalledWith('myapp-evm:key', 'value'); + }); + + it('should create default storage with prefix when no user storage', async () => { + const mockAdapter = createMockAdapter(); + + const storage = await createIsolatedStorage({ + instanceId: 'test-multichain', + userStorage: undefined, + createAdapter: async () => mockAdapter, + }); + + expect(storage).toBeInstanceOf(Store); + expect(storage.adapter).toBeInstanceOf(PrefixedStoreAdapter); + + await storage.adapter.get('transport'); + expect(mockAdapter.get).toHaveBeenCalledWith('test-multichain:transport'); + }); + + it('should call createAdapter factory only when no user storage', async () => { + const mockAdapter = createMockAdapter(); + const userStorage = createMockStorage(mockAdapter); + const createAdapterSpy = vi.fn(); + + await createIsolatedStorage({ + instanceId: 'test', + userStorage, + createAdapter: createAdapterSpy, + }); + + expect(createAdapterSpy).not.toHaveBeenCalled(); + }); + + it('should call createAdapter when no user storage provided', async () => { + const mockAdapter = createMockAdapter(); + const createAdapterSpy = vi.fn().mockResolvedValue(mockAdapter); + + await createIsolatedStorage({ + instanceId: 'test', + userStorage: undefined, + createAdapter: createAdapterSpy, + }); + + expect(createAdapterSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('with empty instanceId (no prefixing)', () => { + it('should return user storage as-is when instanceId is empty', async () => { + const mockAdapter = createMockAdapter(); + const userStorage = createMockStorage(mockAdapter); + + const storage = await createIsolatedStorage({ + instanceId: '', + userStorage, + createAdapter: async () => createMockAdapter(), + }); + + // Should return the exact same storage object + expect(storage).toBe(userStorage); + }); + + it('should create unprefixed storage when no user storage and empty instanceId', async () => { + const mockAdapter = createMockAdapter(); + + const storage = await createIsolatedStorage({ + instanceId: '', + userStorage: undefined, + createAdapter: async () => mockAdapter, + }); + + expect(storage).toBeInstanceOf(Store); + // Adapter should NOT be prefixed + expect(storage.adapter).toBe(mockAdapter); + expect(storage.adapter).not.toBeInstanceOf(PrefixedStoreAdapter); + + await storage.adapter.get('key'); + expect(mockAdapter.get).toHaveBeenCalledWith('key'); // No prefix + }); + }); + + describe('async adapter creation', () => { + it('should support async createAdapter factory', async () => { + const mockAdapter = createMockAdapter(); + const asyncFactory = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return mockAdapter; + }); + + const storage = await createIsolatedStorage({ + instanceId: 'async-test', + userStorage: undefined, + createAdapter: asyncFactory, + }); + + expect(asyncFactory).toHaveBeenCalled(); + expect(storage.adapter).toBeInstanceOf(PrefixedStoreAdapter); + }); + + it('should support sync createAdapter factory', async () => { + const mockAdapter = createMockAdapter(); + const syncFactory = vi.fn().mockReturnValue(mockAdapter); + + const storage = await createIsolatedStorage({ + instanceId: 'sync-test', + userStorage: undefined, + createAdapter: syncFactory, + }); + + expect(syncFactory).toHaveBeenCalled(); + expect(storage.adapter).toBeInstanceOf(PrefixedStoreAdapter); + }); + }); +}); diff --git a/packages/connect-multichain/src/store/create-storage.ts b/packages/connect-multichain/src/store/create-storage.ts new file mode 100644 index 00000000..822305d1 --- /dev/null +++ b/packages/connect-multichain/src/store/create-storage.ts @@ -0,0 +1,62 @@ +import type { StoreAdapter, StoreClient } from '../domain'; + +import { Store } from './index'; +import { PrefixedStoreAdapter } from './adapters/prefixed'; + +/** + * Generates a deterministic instance ID based on dapp name and SDK type. + * This ensures: + * - Same dApp in different tabs shares state (same instanceId) + * - Different SDK types (multichain, evm) are isolated (different suffix) + * + * @param dappName - The dapp name from options + * @param sdkType - The SDK type (e.g., 'multichain', 'evm') + * @returns A deterministic instance ID + */ +export function generateInstanceId(dappName: string, sdkType: string): string { + // Sanitize dapp name: lowercase, replace non-alphanumeric with dashes + const sanitized = dappName.toLowerCase().replace(/[^a-z0-9]/gu, '-'); + return `${sanitized}-${sdkType}`; +} + +/** + * Creates a storage client with optional namespace prefixing for isolation. + * + * @param options - Configuration options + * @param options.instanceId - Instance ID for prefixing (empty string = no prefix) + * @param options.userStorage - User-provided storage client (optional) + * @param options.createAdapter - Factory function to create default adapter + * @returns A configured StoreClient + */ +export async function createIsolatedStorage({ + instanceId, + userStorage, + createAdapter, +}: { + instanceId: string; + userStorage?: StoreClient; + createAdapter: () => Promise | StoreAdapter; +}): Promise { + if (userStorage) { + if (instanceId) { + const prefixedAdapter = new PrefixedStoreAdapter( + userStorage.adapter, + `${instanceId}:`, + ); + return new Store(prefixedAdapter); + } + // Empty instanceId - use as-is (no prefixing) + return userStorage; + } + + // Create default storage + const rawAdapter = await createAdapter(); + if (instanceId) { + const prefixedAdapter = new PrefixedStoreAdapter( + rawAdapter, + `${instanceId}:`, + ); + return new Store(prefixedAdapter); + } + return new Store(rawAdapter); +} diff --git a/playground/browser-playground/src/App.tsx b/playground/browser-playground/src/App.tsx index f100632c..766bb724 100644 --- a/playground/browser-playground/src/App.tsx +++ b/playground/browser-playground/src/App.tsx @@ -226,12 +226,20 @@ function App() { className="min-h-screen bg-gray-50 flex justify-center" >
-

- MetaMask MultiChain API Test Dapp -

+
+

+ MetaMask MultiChain API Test Dapp +

+ + Isolation Experiments → + +
(null); + const [state, setState] = useState({ status: 'disconnected' }); + const [isInitializing, setIsInitializing] = useState(true); + + // Initialize the SDK + useEffect(() => { + let mounted = true; + + const init = async () => { + try { + const client = await createMultichainClient({ + dapp: DAPP_CONFIG, + api: { supportedNetworks: SUPPORTED_NETWORKS }, + // instanceId will be auto-generated: 'experiment-app-multichain' + }); + + if (!mounted) return; + + clientRef.current = client; + + // Listen for session changes + client.on('wallet_sessionChanged', (session: unknown) => { + const sessionData = session as SessionData | undefined; + if (sessionData && Object.keys(sessionData.sessionScopes || {}).length > 0) { + setState({ status: 'connected', session: sessionData }); + } else { + setState({ status: 'disconnected' }); + } + }); + + // Listen for status changes + client.on('stateChanged', (status: unknown) => { + if (status === 'connecting') { + setState((prev) => ({ ...prev, status: 'connecting' })); + } + }); + + // Check if already connected (session will come through event) + if (client.status === 'connected') { + // Wait for session event, or set connected without session + setState({ status: 'connected' }); + } + + setIsInitializing(false); + } catch (error) { + console.error('Failed to initialize SDK:', error); + setState({ + status: 'disconnected', + error: error instanceof Error ? error.message : 'Unknown error', + }); + setIsInitializing(false); + } + }; + + init(); + + return () => { + mounted = false; + }; + }, []); + + const connect = useCallback(async () => { + if (!clientRef.current) return; + + setState((prev) => ({ ...prev, status: 'connecting' })); + + try { + // connect requires (scopes, caipAccountIds) + await clientRef.current.connect(['eip155:1'] as Scope[], []); + } catch (error) { + console.error('Connect failed:', error); + setState({ + status: 'disconnected', + error: error instanceof Error ? error.message : 'Connection failed', + }); + } + }, []); + + const disconnect = useCallback(async () => { + if (!clientRef.current) return; + + try { + await clientRef.current.disconnect(); + setState({ status: 'disconnected' }); + } catch (error) { + console.error('Disconnect failed:', error); + } + }, []); + + // Extract accounts from session + const accounts: string[] = []; + if (state.session?.sessionScopes) { + for (const scopeData of Object.values(state.session.sessionScopes)) { + const scopeAccounts = (scopeData as { accounts?: string[] }).accounts; + if (scopeAccounts) { + accounts.push(...scopeAccounts); + } + } + } + + // Get first chain from session + const chainId = state.session?.sessionScopes + ? Object.keys(state.session.sessionScopes)[0] + : ''; + + if (isInitializing) { + return ( +
+

Initializing SDK...

+
+ ); + } + + return ( +
+ {/* Experiment Info */} +
+

+ Experiment 1: Single Client Baseline +

+

+ This experiment tests a single multichain client. Connect, verify the + session is stored with a prefixed key, then disconnect. +

+
+

+ Expected instanceId:{' '} + experiment-app-multichain +

+

+ Storage keys should be prefixed with:{' '} + experiment-app-multichain: +

+
+
+ + {/* Client Card */} +
+ + {state.status === 'disconnected' && ( + Connect + )} + {state.status === 'connecting' && ( + {}} disabled> + Connecting... + + )} + {state.status === 'connected' && ( + + Disconnect + + )} + + + {/* Instructions */} +
+

Checklist

+
    +
  • + + Click Connect → QR code appears +
  • +
  • + + Scan with MetaMask Mobile → Connected +
  • +
  • + + + Check Storage State below → Keys prefixed with{' '} + experiment-app-multichain: + +
  • +
  • + + Refresh page → Session restores automatically +
  • +
  • + + Click Disconnect → Status changes, storage cleared +
  • +
+
+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/Experiment2.tsx b/playground/browser-playground/src/experiments/Experiment2.tsx new file mode 100644 index 00000000..24c5b84c --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment2.tsx @@ -0,0 +1,318 @@ +/** + * Experiment 2: Two Multichain Clients (Same Type) + * + * Goal: Test if two instances of the SAME SDK type share state (they should) + * + * Setup: + * - Two createMultichainClient instances with SAME dapp.name + * - They SHOULD share state (same instanceId) + * + * Validates: + * - Connect on Client A → Client B also connected + * - Disconnect on Client A → Client B also disconnected + * - Same storage keys for both + * - 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 { ConnectionCard, ActionButton } from './shared'; + +const DAPP_CONFIG = { + name: 'Experiment App', + url: 'https://experiment.metamask.io', +}; + +// Infura-free public RPC endpoints +const SUPPORTED_NETWORKS = { + 'eip155:1': 'https://eth.llamarpc.com', + 'eip155:11155111': 'https://rpc.sepolia.org', +}; + +type ClientState = { + status: 'disconnected' | 'connecting' | 'connected'; + session?: SessionData | undefined; + error?: string | undefined; +}; + +export function Experiment2() { + // Client A + const clientARef = useRef(null); + const [stateA, setStateA] = useState({ status: 'disconnected' }); + + // Client B + const clientBRef = useRef(null); + const [stateB, setStateB] = useState({ status: 'disconnected' }); + + const [isInitializing, setIsInitializing] = useState(true); + + // Initialize both SDK clients + useEffect(() => { + let mounted = true; + + const init = async () => { + try { + // Create Client A + const clientA = await createMultichainClient({ + dapp: DAPP_CONFIG, + api: { supportedNetworks: SUPPORTED_NETWORKS }, + // instanceId will be auto-generated: 'experiment-app-multichain' + }); + + if (!mounted) return; + clientARef.current = clientA; + + // Listen for Client A session changes + clientA.on('wallet_sessionChanged', (session: unknown) => { + const sessionData = session as SessionData | undefined; + if (sessionData && Object.keys(sessionData.sessionScopes || {}).length > 0) { + setStateA({ status: 'connected', session: sessionData }); + } else { + setStateA({ status: 'disconnected' }); + } + }); + + // Check if Client A already connected + if (clientA.status === 'connected') { + setStateA({ status: 'connected' }); + } + + // Create Client B (with SAME dapp config - should share state) + const clientB = await createMultichainClient({ + dapp: DAPP_CONFIG, // Same dapp.name! + api: { supportedNetworks: SUPPORTED_NETWORKS }, + // instanceId will be auto-generated: 'experiment-app-multichain' (same as A) + }); + + if (!mounted) return; + clientBRef.current = clientB; + + // Listen for Client B session changes + clientB.on('wallet_sessionChanged', (session: unknown) => { + const sessionData = session as SessionData | undefined; + if (sessionData && Object.keys(sessionData.sessionScopes || {}).length > 0) { + setStateB({ status: 'connected', session: sessionData }); + } else { + setStateB({ status: 'disconnected' }); + } + }); + + // Check if Client B already connected + if (clientB.status === 'connected') { + setStateB({ status: 'connected' }); + } + + setIsInitializing(false); + } catch (error) { + console.error('Failed to initialize SDKs:', error); + setIsInitializing(false); + } + }; + + init(); + + return () => { + mounted = false; + }; + }, []); + + // Client A connect/disconnect + const connectA = useCallback(async () => { + if (!clientARef.current) return; + setStateA((prev) => ({ ...prev, status: 'connecting' })); + try { + await clientARef.current.connect(['eip155:1'] as Scope[], []); + } catch (error) { + setStateA({ + status: 'disconnected', + error: error instanceof Error ? error.message : 'Connection failed', + }); + } + }, []); + + const disconnectA = useCallback(async () => { + if (!clientARef.current) return; + await clientARef.current.disconnect(); + setStateA({ status: 'disconnected' }); + }, []); + + // Client B connect/disconnect + const connectB = useCallback(async () => { + if (!clientBRef.current) return; + setStateB((prev) => ({ ...prev, status: 'connecting' })); + try { + await clientBRef.current.connect(['eip155:1'] as Scope[], []); + } catch (error) { + setStateB({ + status: 'disconnected', + error: error instanceof Error ? error.message : 'Connection failed', + }); + } + }, []); + + const disconnectB = useCallback(async () => { + if (!clientBRef.current) return; + await clientBRef.current.disconnect(); + setStateB({ status: 'disconnected' }); + }, []); + + // Extract accounts from sessions + const getAccounts = (session?: SessionData): string[] => { + if (!session?.sessionScopes) return []; + const accounts: string[] = []; + for (const scopeData of Object.values(session.sessionScopes)) { + const scopeAccounts = (scopeData as { accounts?: string[] }).accounts; + if (scopeAccounts) { + accounts.push(...scopeAccounts); + } + } + return accounts; + }; + + const getChainId = (session?: SessionData): string | undefined => { + if (!session?.sessionScopes) return undefined; + const keys = Object.keys(session.sessionScopes); + return keys[0] || undefined; + }; + + if (isInitializing) { + return ( +
+

Initializing SDKs...

+
+ ); + } + + return ( +
+ {/* Experiment Info */} +
+

+ Experiment 2: Two Multichain Clients (Same Type) +

+

+ Both clients use the same dapp.name, so they should{' '} + share the same state. Connecting one should make the + other appear connected too. +

+
+

+ Both clients use instanceId:{' '} + experiment-app-multichain +

+

+ Expected behavior: They share storage, so connect + on A should make B connected too. +

+
+
+ + {/* Client Cards */} +
+ {/* Client A */} + + {stateA.status === 'disconnected' && ( + Connect Client A + )} + {stateA.status === 'connecting' && ( + {}} disabled> + Connecting... + + )} + {stateA.status === 'connected' && ( + + Disconnect Client A + + )} + + + {/* Client B */} + + {stateB.status === 'disconnected' && ( + Connect Client B + )} + {stateB.status === 'connecting' && ( + {}} disabled> + Connecting... + + )} + {stateB.status === 'connected' && ( + + Disconnect Client B + + )} + +
+ + {/* Checklist */} +
+

+ Shared State Checklist +

+
    +
  • + + + Connect Client A → Does Client B show Connected? + +
  • +
  • + + + Both cards show the same accounts and{' '} + same chainId + +
  • +
  • + + + Storage shows only one set of keys (prefixed with{' '} + experiment-app-multichain:) + +
  • +
  • + + + Disconnect Client A → Does Client B also show{' '} + Disconnected? + +
  • +
  • + + + Multi-tab test: Open this page in two tabs. + Connect in one tab. Does the other tab show connected? + +
  • +
+
+ + {/* Note */} +
+

Note

+

+ Because both clients share the same instanceId, they + read/write to the same storage keys. However, the React state is + separate - you may need to refresh or wait for events to propagate + between clients. +

+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/Experiment3.tsx b/playground/browser-playground/src/experiments/Experiment3.tsx new file mode 100644 index 00000000..49a64ac5 --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment3.tsx @@ -0,0 +1,352 @@ +/** + * Experiment 3: Multichain + EVM (Different Types) + * + * Goal: Test that different SDK types are isolated + * + * Setup: + * - One createMultichainClient + * - One createEVMClient + * - Same dapp.name for both + * + * Validates: + * - Connect Multichain → EVM NOT connected + * - Connect EVM → Multichain NOT connected + * - Disconnect Multichain → EVM unaffected + * - 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 { ConnectionCard, ActionButton } from './shared'; + +const DAPP_CONFIG = { + name: 'Experiment App', + url: 'https://experiment.metamask.io', +}; + +// Infura-free public RPC endpoints +const SUPPORTED_NETWORKS_CAIP = { + 'eip155:1': 'https://eth.llamarpc.com', + 'eip155:11155111': 'https://rpc.sepolia.org', +}; + +const SUPPORTED_NETWORKS_HEX = { + '0x1': 'https://eth.llamarpc.com', + '0xaa36a7': 'https://rpc.sepolia.org', +} as const; + +type MultichainState = { + status: 'disconnected' | 'connecting' | 'connected'; + session?: SessionData | undefined; + error?: string | undefined; +}; + +type EVMState = { + status: 'disconnected' | 'connecting' | 'connected'; + accounts: string[]; + chainId?: string | undefined; + error?: string | undefined; +}; + +export function Experiment3() { + const multichainRef = useRef(null); + const evmRef = useRef(null); + + const [multichainState, setMultichainState] = useState({ + status: 'disconnected', + }); + const [evmState, setEvmState] = useState({ + status: 'disconnected', + accounts: [], + }); + const [isInitializing, setIsInitializing] = useState(true); + + // Initialize both SDKs + useEffect(() => { + let mounted = true; + + const init = async () => { + try { + // Initialize Multichain client + const multichainClient = await createMultichainClient({ + dapp: DAPP_CONFIG, + api: { supportedNetworks: SUPPORTED_NETWORKS_CAIP }, + // instanceId auto-generated: 'experiment-app-multichain' + }); + + if (!mounted) return; + multichainRef.current = multichainClient; + + // Listen for multichain session changes + multichainClient.on( + 'wallet_sessionChanged', + (session: unknown) => { + const sessionData = session as SessionData | undefined; + if (sessionData && Object.keys(sessionData.sessionScopes || {}).length > 0) { + setMultichainState({ status: 'connected', session: sessionData }); + } else { + setMultichainState({ status: 'disconnected' }); + } + }, + ); + + // Check if multichain already connected (session comes via event) + if (multichainClient.status === 'connected') { + setMultichainState({ status: 'connected' }); + } + + // Initialize EVM client + const evmClient = await createEVMClient({ + dapp: DAPP_CONFIG, + api: { supportedNetworks: SUPPORTED_NETWORKS_HEX }, + // instanceId auto-generated: 'experiment-app-evm' + }); + + if (!mounted) return; + evmRef.current = evmClient; + + const provider = evmClient.getProvider(); + + // Listen for EVM events + provider.on('connect', () => { + setEvmState({ + status: 'connected', + accounts: evmClient.accounts as string[], + chainId: evmClient.selectedChainId ?? undefined, + }); + }); + + provider.on('disconnect', () => { + setEvmState({ status: 'disconnected', accounts: [] }); + }); + + provider.on('accountsChanged', (accounts: unknown) => { + setEvmState((prev) => ({ ...prev, accounts: accounts as string[] })); + }); + + provider.on('chainChanged', (chainId: unknown) => { + setEvmState((prev) => ({ ...prev, chainId: chainId as string })); + }); + + // Check if EVM already connected + if (evmClient.status === 'connected' && evmClient.accounts.length > 0) { + setEvmState({ + status: 'connected', + accounts: evmClient.accounts as string[], + chainId: evmClient.selectedChainId ?? undefined, + }); + } + + setIsInitializing(false); + } catch (error) { + console.error('Failed to initialize SDKs:', error); + setIsInitializing(false); + } + }; + + init(); + + return () => { + mounted = false; + }; + }, []); + + // Multichain connect/disconnect + const connectMultichain = useCallback(async () => { + if (!multichainRef.current) return; + setMultichainState((prev) => ({ ...prev, status: 'connecting' })); + try { + // connect requires (scopes, caipAccountIds) + await multichainRef.current.connect(['eip155:1'] as Scope[], []); + } catch (error) { + setMultichainState({ + status: 'disconnected', + error: error instanceof Error ? error.message : 'Connection failed', + }); + } + }, []); + + const disconnectMultichain = useCallback(async () => { + if (!multichainRef.current) return; + await multichainRef.current.disconnect(); + setMultichainState({ status: 'disconnected' }); + }, []); + + // EVM connect/disconnect + const connectEVM = useCallback(async () => { + if (!evmRef.current) return; + setEvmState((prev) => ({ ...prev, status: 'connecting' })); + try { + const result = await evmRef.current.connect({ chainIds: ['0x1'] }); + setEvmState({ + status: 'connected', + accounts: result.accounts, + chainId: result.chainId, + }); + } catch (error) { + setEvmState({ + status: 'disconnected', + accounts: [], + error: error instanceof Error ? error.message : 'Connection failed', + }); + } + }, []); + + const disconnectEVM = useCallback(async () => { + if (!evmRef.current) return; + await evmRef.current.disconnect(); + setEvmState({ status: 'disconnected', accounts: [] }); + }, []); + + // Extract multichain accounts - cast the sessionScopes values properly + const multichainAccounts: string[] = []; + if (multichainState.session?.sessionScopes) { + for (const scopeData of Object.values(multichainState.session.sessionScopes)) { + const accounts = (scopeData as { accounts?: string[] }).accounts; + if (accounts) { + multichainAccounts.push(...accounts); + } + } + } + + const multichainChainId = multichainState.session?.sessionScopes + ? Object.keys(multichainState.session.sessionScopes)[0] + : ''; + + if (isInitializing) { + return ( +
+

Initializing SDKs...

+
+ ); + } + + return ( +
+ {/* Experiment Info */} +
+

+ Experiment 3: Multichain + EVM (Different Types) +

+

+ This experiment tests that Multichain and EVM clients are{' '} + isolated from each other. Connecting one should NOT + affect the other. +

+
+
+

+ Multichain instanceId: +

+ + experiment-app-multichain + +
+
+

+ EVM instanceId: +

+ experiment-app-evm +
+
+
+ + {/* Client Cards */} +
+ {/* Multichain */} + + {multichainState.status === 'disconnected' && ( + Connect + )} + {multichainState.status === 'connecting' && ( + {}} disabled> + Connecting... + + )} + {multichainState.status === 'connected' && ( + + Disconnect + + )} + + + {/* EVM */} + + {evmState.status === 'disconnected' && ( + Connect + )} + {evmState.status === 'connecting' && ( + {}} disabled> + Connecting... + + )} + {evmState.status === 'connected' && ( + + Disconnect + + )} + +
+ + {/* Checklist */} +
+

+ Isolation Checklist +

+
    +
  • + + + Connect Multichain → EVM status stays{' '} + Disconnected + +
  • +
  • + + + Connect EVM (new QR scan) → Multichain status unchanged + +
  • +
  • + + + Storage shows two different prefixes:{' '} + experiment-app-multichain: and{' '} + experiment-app-evm: + +
  • +
  • + + + Disconnect Multichain → EVM stays Connected + +
  • +
  • + + + Disconnect EVM → Multichain unaffected (if still connected) + +
  • +
+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/Experiment4.tsx b/playground/browser-playground/src/experiments/Experiment4.tsx new file mode 100644 index 00000000..32e9fd4b --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment4.tsx @@ -0,0 +1,69 @@ +/** + * Experiment 4: EVM + Wagmi (Shared State) + * + * Goal: Test that EVM and Wagmi share state when configured to + * + * Setup: + * - One createEVMClient with dapp.name: 'Experiment App' + * - Wagmi connector with dapp.name: 'Experiment App' + * - Both use same instanceId (via same dapp.name + '-evm' suffix) + * + * Validates: + * - Connect EVM → Wagmi sees connected + * - Connect Wagmi → EVM sees connected + * - Disconnect EVM → Wagmi also disconnected + * - Sign on EVM → same result as sign on Wagmi + * - Same storage keys + */ +import { ConnectionCard } from './shared'; + +export function Experiment4() { + return ( +
+ {/* Experiment Info */} +
+

+ Experiment 4: EVM + Wagmi (Shared State) +

+

+ This experiment tests that EVM and Wagmi clients{' '} + share the same state when using the same dapp.name. +

+
+

+ Both clients use instanceId:{' '} + experiment-app-evm +

+
+
+ + {/* Coming Soon */} +
+ +

Coming soon...

+
+ + +

Coming soon...

+
+
+ +
+

+ This experiment is a placeholder. Implementation requires configuring + Wagmi with matching dapp.name. +

+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/Experiment5.tsx b/playground/browser-playground/src/experiments/Experiment5.tsx new file mode 100644 index 00000000..77798da4 --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment5.tsx @@ -0,0 +1,75 @@ +/** + * Experiment 5: EVM + Wagmi (Isolated State) + * + * Goal: Test full isolation if Option A (shared) doesn't work + * + * Setup: + * - One createEVMClient with instanceId: 'experiment-evm' + * - Wagmi connector with instanceId: 'experiment-wagmi-evm' + * + * Validates: + * - Connect EVM → Wagmi NOT connected + * - Connect Wagmi → EVM NOT connected + * - Each has own QR scan + * - Different storage keys + */ +import { ConnectionCard } from './shared'; + +export function Experiment5() { + return ( +
+ {/* Experiment Info */} +
+

+ Experiment 5: EVM + Wagmi (Isolated State) +

+

+ This experiment tests full isolation between EVM and + Wagmi using different instanceIds. +

+
+
+

+ EVM instanceId: +

+ experiment-evm +
+
+

+ Wagmi instanceId: +

+ experiment-wagmi-evm +
+
+
+ + {/* Coming Soon */} +
+ +

Coming soon...

+
+ + +

Coming soon...

+
+
+ +
+

+ This experiment is a placeholder. Implementation requires passing + explicit instanceId to both clients. +

+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/Experiment6.tsx b/playground/browser-playground/src/experiments/Experiment6.tsx new file mode 100644 index 00000000..14ae97aa --- /dev/null +++ b/playground/browser-playground/src/experiments/Experiment6.tsx @@ -0,0 +1,93 @@ +/** + * Experiment 6: All Three (Full Integration) + * + * Goal: The "real world" test with all three SDK types + * + * Setup: + * - createMultichainClient with dapp.name: 'Experiment App' + * - createEVMClient with dapp.name: 'Experiment App' + * - Wagmi connector with dapp.name: 'Experiment App' + * + * Expected Behavior: + * - Multichain is isolated (instanceId: experiment-app-multichain) + * - EVM and Wagmi share state (instanceId: experiment-app-evm) + */ +import { ConnectionCard } from './shared'; + +export function Experiment6() { + return ( +
+ {/* Experiment Info */} +
+

+ Experiment 6: All Three (Full Integration) +

+

+ This experiment tests all three SDK types together. Multichain should + be isolated, while EVM and Wagmi share state. +

+
+
+

+ Multichain: +

+ + experiment-app-multichain + +
+
+

+ EVM: +

+ experiment-app-evm +
+
+

+ Wagmi: +

+ + experiment-app-evm (shared) + +
+
+
+ + {/* Coming Soon */} +
+ +

Coming soon...

+
+ + +

Coming soon...

+
+ + +

Coming soon...

+
+
+ +
+

+ This experiment is a placeholder. It will combine all three SDK types + to validate the full isolation strategy. +

+
+
+ ); +} diff --git a/playground/browser-playground/src/experiments/ExperimentsApp.tsx b/playground/browser-playground/src/experiments/ExperimentsApp.tsx new file mode 100644 index 00000000..32764bc6 --- /dev/null +++ b/playground/browser-playground/src/experiments/ExperimentsApp.tsx @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react'; +import { ExperimentsLayout, type ExperimentId } from './shared'; +import { Experiment1 } from './Experiment1'; +import { Experiment2 } from './Experiment2'; +import { Experiment3 } from './Experiment3'; +import { Experiment4 } from './Experiment4'; +import { Experiment5 } from './Experiment5'; +import { Experiment6 } from './Experiment6'; + +// 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)) { + return hash as ExperimentId; + } + return 'exp1'; +} + +/** + * Main component for the experiments page. + * Uses URL hash for navigation (e.g., /experiments#exp1) + */ +export function ExperimentsApp() { + const [currentExperiment, setCurrentExperiment] = useState( + getExperimentFromHash, + ); + + // Sync with URL hash + useEffect(() => { + const handleHashChange = () => { + setCurrentExperiment(getExperimentFromHash()); + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + // Update URL when experiment changes + const handleExperimentChange = (id: ExperimentId) => { + window.location.hash = id; + setCurrentExperiment(id); + }; + + // Render the current experiment + const renderExperiment = () => { + switch (currentExperiment) { + case 'exp1': + return ; + case 'exp2': + return ; + case 'exp3': + return ; + case 'exp4': + return ; + case 'exp5': + return ; + case 'exp6': + return ; + default: + return ; + } + }; + + return ( + + {renderExperiment()} + + ); +} diff --git a/playground/browser-playground/src/experiments/index.ts b/playground/browser-playground/src/experiments/index.ts new file mode 100644 index 00000000..7c73aefd --- /dev/null +++ b/playground/browser-playground/src/experiments/index.ts @@ -0,0 +1,2 @@ +export { ExperimentsApp } from './ExperimentsApp'; +export * from './shared'; diff --git a/playground/browser-playground/src/experiments/shared/ConnectionCard.tsx b/playground/browser-playground/src/experiments/shared/ConnectionCard.tsx new file mode 100644 index 00000000..b56b2a76 --- /dev/null +++ b/playground/browser-playground/src/experiments/shared/ConnectionCard.tsx @@ -0,0 +1,141 @@ +import type { ReactNode } from 'react'; + +export type ConnectionStatus = + | 'disconnected' + | 'connecting' + | 'connected' + | 'error'; + +type ConnectionCardProps = { + title: string; + subtitle?: string | undefined; + status: ConnectionStatus; + instanceId?: string | undefined; + accounts?: string[] | undefined; + chainId?: string | undefined; + error?: string | undefined; + children?: ReactNode | undefined; +}; + +const statusColors: Record = { + disconnected: 'bg-gray-200 text-gray-700', + connecting: 'bg-yellow-200 text-yellow-800', + connected: 'bg-green-200 text-green-800', + error: 'bg-red-200 text-red-800', +}; + +const statusLabels: Record = { + disconnected: 'Disconnected', + connecting: 'Connecting...', + connected: 'Connected', + error: 'Error', +}; + +/** + * A reusable card component that shows the connection state for an SDK client. + */ +export function ConnectionCard({ + title, + subtitle, + status, + instanceId, + accounts, + chainId, + error, + children, +}: ConnectionCardProps) { + return ( +
+ {/* Header */} +
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ + {statusLabels[status]} + +
+ + {/* Instance ID */} + {instanceId && ( +
+

Instance ID

+

+ {instanceId} +

+
+ )} + + {/* Connection Details */} + {status === 'connected' && ( +
+ {chainId && ( +
+

Chain ID

+

{chainId}

+
+ )} + {accounts && accounts.length > 0 && ( +
+

+ Accounts ({accounts.length}) +

+
+ {accounts.map((account, i) => ( +

+ {account} +

+ ))} +
+
+ )} +
+ )} + + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} + {children &&
{children}
} +
+ ); +} + +type ActionButtonProps = { + onClick: () => void; + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'danger'; + children: ReactNode; +}; + +const buttonVariants = { + primary: 'bg-blue-500 hover:bg-blue-600 text-white', + secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800', + danger: 'bg-red-500 hover:bg-red-600 text-white', +}; + +export function ActionButton({ + onClick, + disabled, + variant = 'primary', + children, +}: ActionButtonProps) { + return ( + + ); +} diff --git a/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx new file mode 100644 index 00000000..79671d4e --- /dev/null +++ b/playground/browser-playground/src/experiments/shared/ExperimentsLayout.tsx @@ -0,0 +1,144 @@ +import type { ReactNode } from 'react'; +import { StateVisualizer } from './StateVisualizer'; + +export type ExperimentId = + | 'exp1' + | 'exp2' + | 'exp3' + | 'exp4' + | 'exp5' + | 'exp6'; + +type ExperimentInfo = { + id: ExperimentId; + title: string; + description: string; +}; + +export const EXPERIMENTS: ExperimentInfo[] = [ + { + id: 'exp1', + title: 'Exp 1: Single Client', + description: 'Baseline test with one multichain client', + }, + { + id: 'exp2', + title: 'Exp 2: Same Type', + description: 'Two multichain clients (should share state)', + }, + { + id: 'exp3', + title: 'Exp 3: Different Types', + description: 'Multichain + EVM (should be isolated)', + }, + { + id: 'exp4', + title: 'Exp 4: EVM + Wagmi', + description: 'EVM and Wagmi with shared state', + }, + { + id: 'exp5', + title: 'Exp 5: Isolated Wagmi', + description: 'EVM and Wagmi fully isolated', + }, + { + id: 'exp6', + title: 'Exp 6: All Three', + description: 'Multichain + EVM + Wagmi together', + }, +]; + +type ExperimentsLayoutProps = { + currentExperiment: ExperimentId; + onExperimentChange: (id: ExperimentId) => void; + children: ReactNode; +}; + +/** + * Layout component for experiments with navigation header and state visualizer. + */ +export function ExperimentsLayout({ + currentExperiment, + onExperimentChange, + children, +}: ExperimentsLayoutProps) { + const currentExp = EXPERIMENTS.find((e) => e.id === currentExperiment); + + return ( +
+ {/* Header with Navigation */} +
+
+
+ {/* Title */} +
+

+ SDK Isolation Experiments +

+ + ← Back to Main + +
+ + {/* Experiment Selector */} +
+ + +
+
+ + {/* Current Experiment Description */} + {currentExp && ( +

{currentExp.description}

+ )} +
+ + {/* Quick Navigation Tabs */} +
+ +
+
+ + {/* Main Content */} +
{children}
+ + {/* State Visualizer */} + +
+ ); +} diff --git a/playground/browser-playground/src/experiments/shared/StateVisualizer.tsx b/playground/browser-playground/src/experiments/shared/StateVisualizer.tsx new file mode 100644 index 00000000..4b85c3b5 --- /dev/null +++ b/playground/browser-playground/src/experiments/shared/StateVisualizer.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState, useCallback } from 'react'; + +type StorageEntry = { + key: string; + value: string; +}; + +/** + * Opens the IndexedDB database and returns all key-value pairs. + */ +async function getIndexedDBEntries(dbName: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName); + + request.onerror = () => { + // Database might not exist yet + resolve([]); + }; + + request.onsuccess = () => { + const db = request.result; + const storeNames = Array.from(db.objectStoreNames); + + if (storeNames.length === 0) { + db.close(); + resolve([]); + return; + } + + const entries: StorageEntry[] = []; + const transaction = db.transaction(storeNames, 'readonly'); + + let storesProcessed = 0; + storeNames.forEach((storeName) => { + const store = transaction.objectStore(storeName); + const cursorRequest = store.openCursor(); + + cursorRequest.onsuccess = (event) => { + const cursor = (event.target as IDBRequest) + .result; + if (cursor) { + const value = + typeof cursor.value === 'string' + ? cursor.value + : JSON.stringify(cursor.value); + entries.push({ + key: `${storeName}/${String(cursor.key)}`, + value: value.length > 100 ? `${value.slice(0, 100)}...` : value, + }); + cursor.continue(); + } else { + storesProcessed++; + if (storesProcessed === storeNames.length) { + db.close(); + resolve(entries); + } + } + }; + + cursorRequest.onerror = () => { + storesProcessed++; + if (storesProcessed === storeNames.length) { + db.close(); + resolve(entries); + } + }; + }); + }; + }); +} + +/** + * A debug component that shows the current state of IndexedDB storage. + * Helps visualize that storage keys are properly prefixed for isolation. + */ +export function StateVisualizer() { + const [entries, setEntries] = useState([]); + const [isExpanded, setIsExpanded] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const refreshEntries = useCallback(async () => { + setIsLoading(true); + try { + const dbEntries = await getIndexedDBEntries('mmsdk-kv-store'); + setEntries(dbEntries.sort((a, b) => a.key.localeCompare(b.key))); + } catch (error) { + console.error('Failed to read IndexedDB:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refreshEntries(); + // Refresh every 2 seconds while component is mounted + const interval = setInterval(refreshEntries, 2000); + return () => clearInterval(interval); + }, [refreshEntries]); + + const clearAllStorage = useCallback(async () => { + // Clear IndexedDB + const databases = await indexedDB.databases(); + for (const db of databases) { + if (db.name) { + indexedDB.deleteDatabase(db.name); + } + } + // Clear localStorage + localStorage.clear(); + // Refresh the view + await refreshEntries(); + }, [refreshEntries]); + + // Group entries by prefix (before the first colon) + const groupedEntries = entries.reduce>( + (acc, entry) => { + const colonIndex = entry.key.indexOf(':'); + const prefix = + colonIndex > 0 ? entry.key.slice(0, colonIndex) : '(no prefix)'; + if (!acc[prefix]) { + acc[prefix] = []; + } + acc[prefix].push(entry); + return acc; + }, + {}, + ); + + return ( +
+
setIsExpanded(!isExpanded)} + > +

+ 🔍 Storage State + + ({entries.length} entries) + + {isLoading && ( + refreshing... + )} +

+
+ + + {isExpanded ? '▼' : '▲'} +
+
+ + {isExpanded && ( +
+ {Object.keys(groupedEntries).length === 0 ? ( +

No storage entries

+ ) : ( + Object.entries(groupedEntries).map(([prefix, prefixEntries]) => ( +
+

+ {prefix} +

+ + + {prefixEntries.map((entry) => ( + + + + + ))} + +
+ {entry.key} + + {entry.value} +
+
+ )) + )} +
+ )} +
+ ); +} diff --git a/playground/browser-playground/src/experiments/shared/index.ts b/playground/browser-playground/src/experiments/shared/index.ts new file mode 100644 index 00000000..278ca8af --- /dev/null +++ b/playground/browser-playground/src/experiments/shared/index.ts @@ -0,0 +1,8 @@ +export { StateVisualizer } from './StateVisualizer'; +export { ConnectionCard, ActionButton } from './ConnectionCard'; +export type { ConnectionStatus } from './ConnectionCard'; +export { + ExperimentsLayout, + EXPERIMENTS, +} from './ExperimentsLayout'; +export type { ExperimentId } from './ExperimentsLayout'; diff --git a/playground/browser-playground/src/index.tsx b/playground/browser-playground/src/index.tsx index 1eefbae9..267c125c 100644 --- a/playground/browser-playground/src/index.tsx +++ b/playground/browser-playground/src/index.tsx @@ -11,6 +11,7 @@ import { SDKProvider } from './sdk/SDKProvider'; import { LegacyEVMSDKProvider } from './sdk/LegacyEVMSDKProvider'; import { SolanaWalletProvider } from './sdk/SolanaProvider'; import { wagmiConfig } from './wagmi/config'; +import { ExperimentsApp } from './experiments'; const queryClient = new QueryClient({ defaultOptions: { @@ -31,24 +32,40 @@ const persister = createSyncStoragePersister({ deserialize, }); +// Check if we're on the experiments page +// Uses URL search param: ?experiments or ?experiments=true +const isExperimentsPage = + window.location.search.includes('experiments') || + window.location.pathname.includes('experiments'); + const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ); -root.render( - - - - - - - - - - - - - , -); + +// Experiments page doesn't need the SDK providers (it creates its own) +if (isExperimentsPage) { + root.render( + + + , + ); +} else { + root.render( + + + + + + + + + + + + + , + ); +}