diff --git a/packages/connect-multichain/src/connect.test.ts b/packages/connect-multichain/src/connect.test.ts index fd28ca24..aee5830a 100644 --- a/packages/connect-multichain/src/connect.test.ts +++ b/packages/connect-multichain/src/connect.test.ts @@ -166,7 +166,12 @@ function testSuite({ 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 @@ -260,7 +265,12 @@ function testSuite({ 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); @@ -377,7 +387,12 @@ function testSuite({ 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(() => { @@ -446,7 +461,12 @@ function testSuite({ 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; diff --git a/packages/connect-multichain/src/init.test.ts b/packages/connect-multichain/src/init.test.ts index 55b30c0a..c8300093 100644 --- a/packages/connect-multichain/src/init.test.ts +++ b/packages/connect-multichain/src/init.test.ts @@ -137,7 +137,14 @@ function testSuite({ 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(); + } }, ); diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index aa90ab9c..63eaf6a4 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -83,6 +83,13 @@ export class MetaMaskConnectMultichain extends MultichainCore { #listener: (() => void | Promise) | 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; } @@ -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'; } } @@ -529,6 +559,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { async #setupDefaultTransport(): Promise { this.status = 'connecting'; + this.#isPassiveTransport = false; await this.storage.setTransport(TransportType.Browser); const transport = new DefaultTransport(); this.#listener = transport.onNotification( @@ -824,7 +855,16 @@ 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'); @@ -832,6 +872,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { this.#listener = undefined; this.#beforeUnloadListener = undefined; this.#transport = undefined; + this.#isPassiveTransport = false; this.#providerTransportWrapper.clearNotificationCallbacks(); this.#dappClient = undefined; } diff --git a/packages/connect-multichain/src/multichain/transports/default/index.ts b/packages/connect-multichain/src/multichain/transports/default/index.ts index bd9d139d..7090e976 100644 --- a/packages/connect-multichain/src/multichain/transports/default/index.ts +++ b/packages/connect-multichain/src/multichain/transports/default/index.ts @@ -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); } @@ -302,6 +303,10 @@ 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 () => { @@ -309,6 +314,33 @@ export class DefaultTransport implements ExtendedTransport { }; } + /** + * 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 { // 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