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
28 changes: 24 additions & 4 deletions packages/connect-multichain/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ function testSuite<T extends MultichainOptions>({
t.expect(sdk.status).toBe('loaded');
// Provider is always available via wrapper transport (handles connection state internally)
t.expect(sdk.provider).toBeDefined();
t.expect(() => sdk.transport).toThrow();
if (platform === 'web') {
// Web env with extension: passive transport exists for event listening
t.expect(sdk.transport).toBeDefined();
} else {
t.expect(() => sdk.transport).toThrow();
}

// Expect sdk.connect to reject if transport cannot connect
// Add timeout wrapper for web-mobile platform to prevent hanging
Expand Down Expand Up @@ -260,7 +265,12 @@ function testSuite<T extends MultichainOptions>({
t.expect(sdk.status).toBe('loaded');
// Provider is always available via wrapper transport (handles connection state internally)
t.expect(sdk.provider).toBeDefined();
t.expect(() => sdk.transport).toThrow();
if (platform === 'web') {
// Web env with extension: passive transport exists for event listening
t.expect(sdk.transport).toBeDefined();
} else {
t.expect(() => sdk.transport).toThrow();
}

await sdk.connect(scopes, caipAccountIds);

Expand Down Expand Up @@ -377,7 +387,12 @@ function testSuite<T extends MultichainOptions>({
unloadSpy = t.vi.spyOn((sdk as any).options.ui.factory, 'unload');

t.expect(sdk.status).toBe('loaded');
t.expect(() => sdk.transport).toThrow();
if (platform === 'web') {
// Web env with extension: passive transport exists for event listening
t.expect(sdk.transport).toBeDefined();
} else {
t.expect(() => sdk.transport).toThrow();
}

if (platform !== 'web' && platform !== 'web-mobile') {
showModalPromise = waitForInstallModal(sdk).catch(() => {
Expand Down Expand Up @@ -446,7 +461,12 @@ function testSuite<T extends MultichainOptions>({
sdk = await createSDK(testOptions);

t.expect(sdk.status).toBe('loaded');
t.expect(() => sdk.transport).toThrow();
if (platform === 'web') {
// Web env with extension: passive transport exists for event listening
t.expect(sdk.transport).toBeDefined();
} else {
t.expect(() => sdk.transport).toThrow();
}

// Add timeout wrapper for web-mobile platform to prevent hanging
let timeoutId: NodeJS.Timeout;
Expand Down
9 changes: 8 additions & 1 deletion packages/connect-multichain/src/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,14 @@ function testSuite<T extends MultichainOptions>({
async () => {
sdk = await createSDK(testOptions);
t.expect(sdk.status).toBe('loaded');
t.expect(() => sdk.transport).toThrow();
if (platform === 'web') {
// In web environments with the extension installed, a passive
// DefaultTransport is created for event listening (e.g., wallet_sessionChanged)
// even before connect() is called.
t.expect(sdk.transport).toBeDefined();
} else {
t.expect(() => sdk.transport).toThrow();
}
},
);

Expand Down
43 changes: 42 additions & 1 deletion packages/connect-multichain/src/multichain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export class MetaMaskConnectMultichain extends MultichainCore {

#listener: (() => void | Promise<void>) | undefined;

/**
* Tracks whether the current transport was set up passively for event
* listening (no SDK-managed connection). When true, disconnect() will
* clean up listeners without sending wallet_revokeSession.
*/
#isPassiveTransport = false;

get status(): ConnectionStatus {
return this._status;
}
Expand Down Expand Up @@ -248,12 +255,35 @@ export class MetaMaskConnectMultichain extends MultichainCore {
await this.transport.connect();
}
this.status = 'connected';
this.#isPassiveTransport = false;
if (this.transport instanceof MWPTransport) {
await this.storage.setTransport(TransportType.MWP);
} else {
await this.storage.setTransport(TransportType.Browser);
}
} else {
// No stored transport — set up a passive DefaultTransport for event
// listening when the extension is available. This enables receiving
// wallet_sessionChanged events before connect() is called, supporting
// configurations where the dapp uses the wallet's own injected provider
// (e.g., window.ethereum or native wallet-standard adapter) but wants
// SDK event notifications.
const platformType = getPlatformType();
const isWeb =
platformType === PlatformType.MetaMaskMobileWebview ||
platformType === PlatformType.DesktopWeb;
const hasExtensionInstalled = await hasExtension();

if (isWeb && hasExtensionInstalled) {
const passiveTransport = new DefaultTransport();
this.#transport = passiveTransport;
this.#providerTransportWrapper.setupNotifcationListener();
this.#listener = passiveTransport.onNotification(
this.#onTransportNotification.bind(this),
);
this.#isPassiveTransport = true;
}

this.status = 'loaded';
}
}
Expand Down Expand Up @@ -529,6 +559,7 @@ export class MetaMaskConnectMultichain extends MultichainCore {

async #setupDefaultTransport(): Promise<DefaultTransport> {
this.status = 'connecting';
this.#isPassiveTransport = false;
await this.storage.setTransport(TransportType.Browser);
const transport = new DefaultTransport();
this.#listener = transport.onNotification(
Expand Down Expand Up @@ -824,14 +855,24 @@ export class MetaMaskConnectMultichain extends MultichainCore {
await this.#listener?.();
this.#beforeUnloadListener?.();

await this.#transport?.disconnect();
if (
this.#isPassiveTransport &&
this.#transport instanceof DefaultTransport
) {
// Passive transport: clean up listeners without sending
// wallet_revokeSession, since the SDK did not create the session.
this.#transport.teardown();
} else {
await this.#transport?.disconnect();
}
await this.storage.removeTransport();

this.emit('stateChanged', 'disconnected');

this.#listener = undefined;
this.#beforeUnloadListener = undefined;
this.#transport = undefined;
this.#isPassiveTransport = false;
this.#providerTransportWrapper.clearNotificationCallbacks();
this.#dappClient = undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ export class DefaultTransport implements ExtendedTransport {
const responseData = event?.data?.data?.data;

if (
(typeof responseData === 'object' &&
responseData.method === 'metamask_chainChanged') ||
responseData.method === 'metamask_accountsChanged'
typeof responseData === 'object' &&
(responseData.method === 'metamask_chainChanged' ||
responseData.method === 'metamask_accountsChanged' ||
responseData.method === 'wallet_sessionChanged')
) {
this.#notifyCallbacks(responseData);
}
Expand Down Expand Up @@ -302,13 +303,44 @@ export class DefaultTransport implements ExtendedTransport {
}

onNotification(callback: (data: unknown) => void): () => void {
// Ensure window.postMessage listeners are active so that extension
// notifications (e.g., wallet_sessionChanged) are captured even before
// connect() is called.
this.#setupMessageListener();
this.#transport.onNotification(callback);
this.#notificationCallbacks.add(callback);
return () => {
this.#notificationCallbacks.delete(callback);
};
}

/**
* Cleans up message listeners and pending requests without sending
* wallet_revokeSession. Used when tearing down a passive transport
* that was set up for event listening without an SDK-managed connection.
*/
teardown(): void {
this.#notificationCallbacks.clear();

if (this.#handleResponseListener) {
// eslint-disable-next-line no-restricted-globals
window.removeEventListener('message', this.#handleResponseListener);
this.#handleResponseListener = undefined;
}

if (this.#handleNotificationListener) {
// eslint-disable-next-line no-restricted-globals
window.removeEventListener('message', this.#handleNotificationListener);
this.#handleNotificationListener = undefined;
}

for (const [, request] of this.#pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error('Transport disconnected'));
}
this.#pendingRequests.clear();
}

async getActiveSession(): Promise<Session | undefined> {
// This code path should never be triggered when the DefaultTransport is being used
// It's only purpose is for exposing the session ID used for deeplinking to the mobile app
Expand Down
Loading