Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ playground/*/*.tsbuildinfo

# env files
**/.env

# Local investigation/POC folder
temp/
4 changes: 4 additions & 0 deletions packages/connect-evm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>` ([#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
Expand Down
218 changes: 214 additions & 4 deletions packages/connect-evm/src/connect.test.ts
Original file line number Diff line number Diff line change
@@ -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<MultichainCore> = {
// 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<any>;

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);
});
});
});
7 changes: 5 additions & 2 deletions packages/connect-evm/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -1008,6 +1010,7 @@ export async function createEVMClient(
try {
const core = await createMultichainClient({
...options,
sdkType: 'evm',
api: {
supportedNetworks: supportedNetworksCaipChainId,
},
Expand Down
10 changes: 6 additions & 4 deletions packages/connect-multichain/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@
const uiOptions: MultichainOptions['ui'] =
platform === 'web-mobile'
? {
...originalSdkOptions.ui,
showInstallModal: false,
preferExtension: false,
}
...originalSdkOptions.ui,

Check failure on line 111 in packages/connect-multichain/src/connect.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Insert `··`
showInstallModal: false,

Check failure on line 112 in packages/connect-multichain/src/connect.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Insert `··`
preferExtension: false,
}
: originalSdkOptions.ui;

mockedData = await beforeEach();
Expand Down Expand Up @@ -593,8 +593,10 @@

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);
Expand Down
20 changes: 20 additions & 0 deletions packages/connect-multichain/src/domain/multichain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultichainOptions, 'storage' | 'ui'> & {
Expand Down
31 changes: 21 additions & 10 deletions packages/connect-multichain/src/index.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
Loading
Loading