diff --git a/packages/connect-multichain/src/multichain/connections/default-connection.ts b/packages/connect-multichain/src/multichain/connections/default-connection.ts new file mode 100644 index 00000000..c9755076 --- /dev/null +++ b/packages/connect-multichain/src/multichain/connections/default-connection.ts @@ -0,0 +1,90 @@ +import type { Connection, ConnectionEvents, ConnectParams } from './types'; +import { type Scope, TransportType } from '../../domain'; +import { EventEmitter } from '../../domain/events'; +import { DefaultTransport } from '../transports/default'; + +/** + * Strategy that talks to the in-page MetaMask provider (extension or in-app + * webview) over the standard `window.postMessage` transport. + * + * In contrast to MWP, this connection is also used in a "passive" mode while + * the SDK is idle so that the SDK can observe `wallet_sessionChanged` events + * emitted by the extension without an explicit user connect action. + */ +export class DefaultConnection + extends EventEmitter + implements Connection +{ + readonly type = TransportType.Browser; + + readonly #transport: DefaultTransport; + + #notificationUnsubscribe: (() => void) | undefined; + + // eslint-disable-next-line no-restricted-syntax -- Constructors can't use hash names; factory is preferred + private constructor(transport: DefaultTransport) { + super(); + this.#transport = transport; + this.#notificationUnsubscribe = transport.onNotification((data) => { + this.emit('notification', data); + }); + } + + /** + * Factory used by `MultichainClient` to construct a fresh connection. + * + * @returns A new `DefaultConnection` with a ready-to-use transport. + */ + static create(): DefaultConnection { + return new DefaultConnection(new DefaultTransport()); + } + + get transport(): DefaultTransport { + return this.#transport; + } + + isConnected(): boolean { + return this.#transport.isConnected(); + } + + /** + * Connect against the in-page MetaMask provider, opening or reusing a CAIP + * session as needed. + * + * @param params - Standard connect parameters. + */ + async connect(params: ConnectParams): Promise { + await this.#transport.connect(params); + } + + /** + * Initialise the transport's message listener and emit an initial + * `wallet_sessionChanged` event without performing a `wallet_createSession` + * request. Used to keep a passive listener open when the SDK is idle. + * + * Mirrors the prior `MultichainClient` behaviour of catching init errors + * with `console.error('Passive init failed:', ...)`. + */ + async initPassive(): Promise { + try { + await this.#transport.init(); + } catch (error) { + console.error('Passive init failed:', error); + } + } + + async disconnect(scopes: Scope[] = []): Promise { + await this.#transport.disconnect(scopes); + } + + /** + * Releases the notification subscription. The DefaultTransport itself has + * no explicit teardown beyond this; the underlying `window` message + * listener is shared across `DefaultTransport` instances and is safe to + * leave in place. + */ + async dispose(): Promise { + this.#notificationUnsubscribe?.(); + this.#notificationUnsubscribe = undefined; + } +} diff --git a/packages/connect-multichain/src/multichain/connections/index.ts b/packages/connect-multichain/src/multichain/connections/index.ts new file mode 100644 index 00000000..d95490ec --- /dev/null +++ b/packages/connect-multichain/src/multichain/connections/index.ts @@ -0,0 +1,10 @@ +export { DefaultConnection } from './default-connection'; +export { MwpConnection } from './mwp-connection'; +export type { + Connection, + ConnectionContext, + ConnectionEvents, + ConnectParams, + MwpConnectFlow, + MwpConnectParams, +} from './types'; diff --git a/packages/connect-multichain/src/multichain/connections/mwp-connection.ts b/packages/connect-multichain/src/multichain/connections/mwp-connection.ts new file mode 100644 index 00000000..e9a265b8 --- /dev/null +++ b/packages/connect-multichain/src/multichain/connections/mwp-connection.ts @@ -0,0 +1,502 @@ +/* eslint-disable no-restricted-globals -- window is used intentionally for browser APIs */ +/* eslint-disable no-async-promise-executor -- Async promise executor needed for connect flow */ +/* eslint-disable @typescript-eslint/no-misused-promises -- Async listeners in MWP callbacks */ +/* eslint-disable @typescript-eslint/naming-convention -- External property/event names */ +/* eslint-disable promise/always-return -- Promise executor event handlers */ +import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; +import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; +import type { SessionData } from '@metamask/multichain-api-client'; + +import type { + Connection, + ConnectionContext, + ConnectionEvents, + MwpConnectParams, +} from './types'; +import { + METAMASK_CONNECT_BASE_URL, + METAMASK_DEEPLINK_BASE, + MWP_RELAY_URL, +} from '../../config'; +import { + type ConnectionRequest, + type ConnectionStatus, + getVersion, + type Scope, + TransportType, +} from '../../domain'; +import { EventEmitter } from '../../domain/events'; +import { getPlatformType, isSecure } from '../../domain/platform'; +import { MWPTransport } from '../transports/mwp'; +import { openDeeplink } from '../utils'; + +/** + * Strategy that talks to MetaMask Mobile over the Mobile Wallet Protocol + * (MWP). Owns the `DappClient`/WebSocket lifecycle, the install-modal / + * deeplink / headless sub-flows, and any deeplinks emitted to nudge the + * user back into the MetaMask app. + */ +export class MwpConnection + extends EventEmitter + implements Connection +{ + readonly type = TransportType.MWP; + + readonly #ctx: ConnectionContext; + + readonly #dappClient: DappClient; + + readonly #transport: MWPTransport; + + #notificationUnsubscribe: (() => void) | undefined; + + #beforeUnloadCleanup: (() => void) | undefined; + + // eslint-disable-next-line no-restricted-syntax -- Constructors can't use hash names; factory is preferred + private constructor( + ctx: ConnectionContext, + dappClient: DappClient, + transport: MWPTransport, + ) { + super(); + this.#ctx = ctx; + this.#dappClient = dappClient; + this.#transport = transport; + this.#notificationUnsubscribe = transport.onNotification((data) => { + this.emit('notification', data); + }); + } + + /** + * Factory that performs the async setup (dynamic imports, key manager, + * websocket transport) needed before an MWP connection can be used. + * + * @param ctx - Shared connection context. + * @returns A new `MwpConnection` with a ready-to-use `DappClient` and `MWPTransport`. + */ + static async create(ctx: ConnectionContext): Promise { + const dappClient = await MwpConnection.#createDappClient(ctx); + const { adapter: kvstore } = ctx.options.storage; + const transport = new MWPTransport(dappClient, kvstore); + return new MwpConnection(ctx, dappClient, transport); + } + + static async #createDappClient(ctx: ConnectionContext): Promise { + const [mwpCore, { DappClient: DappClientClass }, { createKeyManager }] = + await Promise.all([ + import('@metamask/mobile-wallet-protocol-core'), + import('@metamask/mobile-wallet-protocol-dapp-client'), + import('../transports/mwp/KeyManager'), + ]); + const keymanager = await createKeyManager(); + + const { adapter: kvstore } = ctx.options.storage; + const sessionstore = await mwpCore.SessionStore.create(kvstore); + // Prefer the browser `WebSocket` when available; otherwise lazy-load the + // `ws` package for Node so we don't pull a Node-only dep into bundles. + const websocket = + // eslint-disable-next-line no-negated-condition -- Matches existing readability preference + typeof window !== 'undefined' + ? WebSocket + : (await import('ws')).WebSocket; + const transport = await mwpCore.WebSocketTransport.create({ + url: MWP_RELAY_URL, + kvstore, + websocket, + }); + return new DappClientClass({ + transport, + sessionstore, + keymanager, + }); + } + + get transport(): MWPTransport { + return this.#transport; + } + + get dappClient(): DappClient { + return this.#dappClient; + } + + isConnected(): boolean { + return this.#transport.isConnected(); + } + + /** + * Execute one of the MWP sub-flows. + * + * @param params - The connect parameters plus a flow selector. + */ + async connect(params: MwpConnectParams): Promise { + this.#installBeforeUnloadGuard(); + const { flow, desktopPreferred = false, ...rest } = params; + + switch (flow) { + case 'deeplink': + await this.#deeplinkConnect(rest); + break; + case 'headless': + await this.#headlessConnect(rest); + break; + case 'install-modal': + await this.#renderInstallModalAsync(desktopPreferred, rest); + break; + default: + throw new Error(`Unknown MWP connect flow: ${String(flow)}`); + } + } + + async disconnect(scopes: Scope[] = []): Promise { + await this.#transport.disconnect(scopes); + } + + async dispose(): Promise { + this.#notificationUnsubscribe?.(); + this.#beforeUnloadCleanup?.(); + this.#notificationUnsubscribe = undefined; + this.#beforeUnloadCleanup = undefined; + } + + // ── Public deeplink helpers ─────────────────────────────────────────────── + + /** + * Used by `connect-evm` (and similar) after a method invocation to nudge + * the user back into the MetaMask app via a stored MWP session. + */ + openSimpleDeeplinkIfNeeded(): void { + const { ui, mobile } = this.#ctx.options; + const { showInstallModal = false } = ui ?? {}; + const secure = isSecure(); + const shouldOpenDeeplink = secure && !showInstallModal; + + if (!shouldOpenDeeplink) { + return; + } + + setTimeout(async () => { + const session = await this.#transport.getActiveSession(); + if (!session) { + throw new Error('No active session found'); + } + + const url = `${METAMASK_DEEPLINK_BASE}/mwp?id=${encodeURIComponent(session.id)}`; + if (mobile?.preferredOpenLink) { + mobile.preferredOpenLink(url, '_self'); + } else { + openDeeplink(this.#ctx.options, url, METAMASK_CONNECT_BASE_URL); + } + }, 10); // small delay to ensure the message encryption and dispatch completes + } + + /** + * Used when a fresh `connect()` call comes in while an MWP connection is + * already in flight — opens a deeplink to the in-progress session so the + * user is brought back to MetaMask Mobile to complete approval. + */ + async openConnectDeeplinkIfNeeded(): Promise { + const { ui } = this.#ctx.options; + const { showInstallModal = false } = ui ?? {}; + const secure = isSecure(); + const shouldOpenDeeplink = secure && !showInstallModal; + + if (!shouldOpenDeeplink) { + return; + } + + const storedSessionRequest = + await this.#transport.getStoredPendingSessionRequest(); + if (!storedSessionRequest) { + return; + } + + const connectionRequest = { + sessionRequest: storedSessionRequest, + metadata: this.#buildConnectionMetadata(), + }; + const deeplink = + this.#ctx.options.ui.factory.createConnectionDeeplink(connectionRequest); + + const universalLink = + this.#ctx.options.ui.factory.createConnectionUniversalLink( + connectionRequest, + ); + + if (this.#ctx.options.mobile?.preferredOpenLink) { + this.#ctx.options.mobile.preferredOpenLink(deeplink, '_self'); + } else { + openDeeplink(this.#ctx.options, deeplink, universalLink); + } + } + + // ── Private sub-flow implementations (moved verbatim from MultichainClient) ── + + #buildConnectionMetadata(): ConnectionRequest['metadata'] { + const metadata: ConnectionRequest['metadata'] = { + dapp: this.#ctx.options.dapp, + sdk: { version: getVersion(), platform: getPlatformType() }, + }; + if (this.#ctx.anonId) { + metadata.analytics = { remote_session_id: this.#ctx.anonId }; + } + return metadata; + } + + #setStatus(status: ConnectionStatus): void { + this.emit('status', status); + } + + async #onBeforeUnload(): Promise { + // Fixes glitch with "connecting" state when modal is still visible and we close screen or refresh + if (this.#ctx.options.ui.factory.modal?.isMounted) { + await this.#ctx.options.storage.removeTransport(); + } + } + + #installBeforeUnloadGuard(): void { + if (this.#beforeUnloadCleanup) { + return; + } + const handler = this.#onBeforeUnload.bind(this); + + if ( + typeof window !== 'undefined' && + typeof window.addEventListener !== 'undefined' + ) { + window.addEventListener('beforeunload', handler); + } + this.#beforeUnloadCleanup = (): void => { + if ( + typeof window !== 'undefined' && + typeof window.removeEventListener !== 'undefined' + ) { + window.removeEventListener('beforeunload', handler); + } + }; + } + + async #renderInstallModalAsync( + desktopPreferred: boolean, + params: Omit, + ): Promise { + const { scopes, caipAccountIds, sessionProperties } = params; + const { storage } = this.#ctx.options; + return new Promise((resolve, reject) => { + // Use Connection Modal + this.#ctx.options.ui.factory + .renderInstallModal( + desktopPreferred, + async () => { + if ( + this.#dappClient.state === 'CONNECTED' || + this.#dappClient.state === 'CONNECTING' + ) { + await this.#dappClient.disconnect(); + } + return new Promise((_resolve) => { + this.#dappClient.on( + 'session_request', + (sessionRequest: SessionRequest) => { + _resolve({ + sessionRequest, + metadata: this.#buildConnectionMetadata(), + }); + }, + ); + + (async (): Promise => { + try { + await this.#transport.connect({ + scopes, + caipAccountIds, + sessionProperties, + }); + await this.#ctx.options.ui.factory.unload(); + this.#ctx.options.ui.factory.modal?.unmount(); + this.#setStatus('connected'); + await storage.setTransport(TransportType.MWP); + } catch (error) { + const { ProtocolError, ErrorCode } = await import( + '@metamask/mobile-wallet-protocol-core' + ); + if (error instanceof ProtocolError) { + if (error.code !== ErrorCode.REQUEST_EXPIRED) { + this.#setStatus('disconnected'); + // Close the modal on error + await this.#ctx.options.ui.factory.unload(error); + reject(error); + } + // If request expires, the QRCode will automatically be regenerated; ignore. + } else { + this.#setStatus('disconnected'); + const normalizedError = + error instanceof Error ? error : new Error(String(error)); + // Close the modal on error + await this.#ctx.options.ui.factory.unload(normalizedError); + reject(normalizedError); + } + } + })().catch(() => { + // Error already handled in the async function + }); + }); + }, + async (error?: Error) => { + if (error) { + await storage.removeTransport(); + reject(error); + } else { + await storage.setTransport(TransportType.MWP); + resolve(); + } + }, + (uri: string) => { + this.emit('display_uri', uri); + }, + ) + .catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + } + + async #headlessConnect( + params: Omit, + ): Promise { + const { scopes, caipAccountIds, sessionProperties } = params; + const { storage } = this.#ctx.options; + return new Promise((resolve, reject) => { + if ( + this.#dappClient.state === 'CONNECTED' || + this.#dappClient.state === 'CONNECTING' + ) { + this.#dappClient.disconnect().catch(() => { + // Ignore disconnect errors + }); + } + + // Listen for session_request to generate and emit the QR code link + this.#dappClient.on( + 'session_request', + (sessionRequest: SessionRequest) => { + const connectionRequest: ConnectionRequest = { + sessionRequest, + metadata: this.#buildConnectionMetadata(), + }; + + const deeplink = + this.#ctx.options.ui.factory.createConnectionDeeplink( + connectionRequest, + ); + this.emit('display_uri', deeplink); + }, + ); + + this.#transport + .connect({ scopes, caipAccountIds, sessionProperties }) + .then(async () => { + this.#setStatus('connected'); + await storage.setTransport(TransportType.MWP); + resolve(); + }) + .catch(async (error) => { + const { ProtocolError } = await import( + '@metamask/mobile-wallet-protocol-core' + ); + if (error instanceof ProtocolError) { + // In headless mode, we don't auto-regenerate QR codes + // since there's no modal to display them + this.#setStatus('disconnected'); + await storage.removeTransport(); + reject(error); + } else { + this.#setStatus('disconnected'); + await storage.removeTransport(); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + } + + async #deeplinkConnect( + params: Omit, + ): Promise { + const { scopes, caipAccountIds, sessionProperties } = params; + const { storage } = this.#ctx.options; + return new Promise(async (resolve, reject) => { + // Handle the response to the initial wallet_createSession request + const dappClientMessageHandler = (payload: unknown): void => { + if ( + typeof payload !== 'object' || + payload === null || + !('data' in payload) + ) { + return; + } + const data = payload.data as { result?: SessionData; error?: unknown }; + if (typeof data === 'object' && data !== null) { + // optimistically assume any error is due to the initial wallet_createSession request failure + if (data.error) { + this.#dappClient.off('message', dappClientMessageHandler); + reject(data.error as Error); + } + // if sessionScopes is set in the result, then this is a response to wallet_createSession + if (data?.result?.sessionScopes) { + this.#dappClient.off('message', dappClientMessageHandler); + // unsure if we need to call resolve here like we do above for reject() + } + } + }; + this.#dappClient.on('message', dappClientMessageHandler); + + let timeout: NodeJS.Timeout | undefined; + + if (this.#transport.isConnected()) { + timeout = setTimeout(() => { + this.openSimpleDeeplinkIfNeeded(); + }, 250); + } else { + this.#dappClient.once( + 'session_request', + (sessionRequest: SessionRequest) => { + const connectionRequest = { + sessionRequest, + metadata: this.#buildConnectionMetadata(), + }; + const deeplink = + this.#ctx.options.ui.factory.createConnectionDeeplink( + connectionRequest, + ); + const universalLink = + this.#ctx.options.ui.factory.createConnectionUniversalLink( + connectionRequest, + ); + + // Emit display_uri event for deeplink connections + this.emit('display_uri', deeplink); + + if (this.#ctx.options.mobile?.preferredOpenLink) { + this.#ctx.options.mobile.preferredOpenLink(deeplink, '_self'); + } else { + openDeeplink(this.#ctx.options, deeplink, universalLink); + } + }, + ); + } + + return this.#transport + .connect({ scopes, caipAccountIds, sessionProperties }) + .then(resolve) + .catch(async (error) => { + await storage.removeTransport(); + this.#dappClient.off('message', dappClientMessageHandler); + reject(error instanceof Error ? error : new Error(String(error))); + }) + .finally(() => { + if (timeout) { + clearTimeout(timeout); + } + }); + }); + } +} diff --git a/packages/connect-multichain/src/multichain/connections/types.ts b/packages/connect-multichain/src/multichain/connections/types.ts new file mode 100644 index 00000000..e45f316d --- /dev/null +++ b/packages/connect-multichain/src/multichain/connections/types.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Event names follow the public event contract */ +import type { SessionProperties } from '@metamask/multichain-api-client'; +import type { CaipAccountId } from '@metamask/utils'; + +import type { + ConnectionStatus, + ExtendedTransport, + MultichainOptions, + Scope, + TransportType, +} from '../../domain'; +import type { EventEmitter } from '../../domain/events'; + +export type ConnectParams = { + scopes: Scope[]; + caipAccountIds: CaipAccountId[]; + sessionProperties?: SessionProperties; + forceRequest?: boolean; +}; + +/** + * Which sub-flow of MWP to execute when connecting. + * - `deeplink`: open the MetaMask app via deeplink (mobile web, secure context). + * - `headless`: run without UI, emit `display_uri` for the consumer to render. + * - `install-modal`: render the install modal with embedded QR code. + */ +export type MwpConnectFlow = 'deeplink' | 'headless' | 'install-modal'; + +export type MwpConnectParams = ConnectParams & { + flow: MwpConnectFlow; + /** When showing the install modal, prefer the desktop install option. */ + desktopPreferred?: boolean; +}; + +export type ConnectionEvents = { + /** Raw notification payload received from the underlying transport. */ + notification: [payload: unknown]; + /** A QR/deeplink URI is ready to be shown to the user. */ + display_uri: [uri: string]; + /** + * Status hint emitted during a multi-step connect flow (e.g. install modal), + * so the parent client can reflect intermediate states before the outer + * connect() promise resolves. + */ + status: [status: ConnectionStatus]; +}; + +/** + * Shared context provided to every Connection. Acts as a small "service + * bag" so connections don't reach back into MultichainClient internals. + */ +export type ConnectionContext = { + /** Live reference to the current MultichainClient options. */ + readonly options: MultichainOptions; + /** Stable anonymous analytics id; undefined on platforms where analytics is disabled. */ + readonly anonId: string | undefined; +}; + +/** + * Common surface shared by all transport-specific connection strategies. + * Concrete classes add a `connect(...)` method with the params shape that + * makes sense for their flow. + */ +export type Connection = { + readonly type: TransportType; + readonly transport: ExtendedTransport; + isConnected(): boolean; + disconnect(scopes?: Scope[]): Promise; + dispose(): Promise; +} & Pick, 'on' | 'off' | 'once' | 'emit'>; diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index c2619a39..7141c171 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -1,10 +1,5 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ /* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable no-restricted-globals */ -/* eslint-disable promise/always-return -- Event handlers */ -/* eslint-disable no-async-promise-executor -- Async promise executor needed for complex flow */ import { analytics } from '@metamask/analytics'; -import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; import { type SessionProperties, @@ -14,11 +9,6 @@ import { } from '@metamask/multichain-api-client'; import type { CaipAccountId, Json } from '@metamask/utils'; -import { - METAMASK_CONNECT_BASE_URL, - METAMASK_DEEPLINK_BASE, - MWP_RELAY_URL, -} from '../config'; import { getVersion, type InvokeMethodOptions, @@ -28,6 +18,12 @@ import { type StoreClient, TransportType, } from '../domain'; +import { + type Connection, + type ConnectionContext, + DefaultConnection, + MwpConnection, +} from './connections'; import { getBaseAnalyticsProperties, isRejectionError, @@ -38,7 +34,6 @@ import { isEnabled as isLoggerEnabled, } from '../domain/logger'; import { - type ConnectionRequest, type ExtendedTransport, MultichainCore, type ConnectionStatus, @@ -51,19 +46,17 @@ import { } from '../domain/platform'; import { RpcClient } from './rpc/handlers/rpcClient'; import { RequestRouter } from './rpc/requestRouter'; -import { DefaultTransport } from './transports/default'; import { MultichainApiClientWrapperTransport } from './transports/multichainApiClientWrapper'; import { getDappId, getGlobalObject, mergeRequestedSessionWithExisting, - openDeeplink, setupDappMetadata, } from './utils'; export { getInfuraRpcUrls } from '../domain/multichain/api/infura'; -// Value substitued by tsup at build time +// Value substituted by tsup at build time declare const __PACKAGE_VERSION__: string | undefined; // ENFORCE NAMESPACE THAT CAN BE DISABLED @@ -76,18 +69,10 @@ export class MetaMaskConnectMultichain extends MultichainCore { readonly #providerTransportWrapper: MultichainApiClientWrapperTransport; - #transport: ExtendedTransport | undefined = undefined; - - #dappClient: DappClient | undefined = undefined; - - #beforeUnloadListener: (() => void) | undefined; - - #transportType?: TransportType; + #connection: Connection | undefined = undefined; public _status: ConnectionStatus = 'pending'; - #listener: (() => void | Promise) | undefined; - #anonId: string | undefined; get status(): ConnectionStatus { @@ -107,21 +92,21 @@ export class MetaMaskConnectMultichain extends MultichainCore { } get transport(): ExtendedTransport { - if (!this.#transport) { + if (!this.#connection) { throw new Error('Transport not initialized, establish connection first'); } - return this.#transport; + return this.#connection.transport; } get dappClient(): DappClient { - if (!this.#dappClient) { + if (!(this.#connection instanceof MwpConnection)) { throw new Error('DappClient not initialized, establish connection first'); } - return this.#dappClient; + return this.#connection.dappClient; } get transportType(): TransportType { - return this.#transportType ?? TransportType.UNKNOWN; + return this.#connection?.type ?? TransportType.UNKNOWN; } get storage(): StoreClient { @@ -221,6 +206,60 @@ export class MetaMaskConnectMultichain extends MultichainCore { return instancePromise; } + // ── Connection orchestration ──────────────────────────────────────────────── + + /** + * Snapshot of the live context handed to every Connection. The getter + * is fresh on each call so connections always see current options/anonId. + * + * @returns A fresh `ConnectionContext` referencing the current options. + */ + #context(): ConnectionContext { + return { options: this.options, anonId: this.#anonId }; + } + + /** + * Subscribes to events emitted by a Connection and translates them into + * `MultichainClient` side-effects (status updates, re-emits, provider + * wrapper wiring). + * + * @param connection - The connection to attach. + */ + #attachConnection(connection: Connection): void { + connection.on('notification', (payload) => { + this.#onTransportNotification(payload).catch((error) => { + logger('Error handling transport notification', error); + }); + }); + connection.on('display_uri', (uri) => { + this.emit('display_uri', uri); + }); + connection.on('status', (status) => { + this.status = status; + }); + this.#providerTransportWrapper.setupTransportNotificationListener(); + } + + /** + * Replace the active connection, disposing the old one first. The + * DefaultConnection has a special-case where it stays alive across + * disconnects so we can keep observing `wallet_sessionChanged`; in those + * cases the same instance may be reused and this is a no-op. + * + * @param next - Connection that should become active. + */ + async #swapConnection(next: Connection): Promise { + if (this.#connection === next) { + return; + } + if (this.#connection) { + await this.#connection.dispose(); + this.#providerTransportWrapper.clearTransportNotificationListener(); + } + this.#connection = next; + this.#attachConnection(next); + } + async #setupAnalytics(): Promise { const platform = getPlatformType(); const isBrowser = @@ -276,83 +315,64 @@ export class MetaMaskConnectMultichain extends MultichainCore { } } - async #getStoredTransport(): Promise { + /** + * Re-hydrates a Connection from a previously persisted transport type, so + * the SDK can pick up where it left off across page reloads. + * + * @returns The rehydrated connection, or `undefined` if storage was empty + * or stale (e.g. extension was uninstalled since the last session). + */ + async #rehydrateConnection(): Promise { const transportType = await this.storage.getTransport(); + if (!transportType) { + return undefined; + } const hasExtensionInstalled = await hasExtension(); - if (transportType) { - if (transportType === TransportType.Browser) { - if (hasExtensionInstalled) { - const apiTransport = new DefaultTransport(); - this.#transport = apiTransport; - this.#transportType = TransportType.Browser; - this.#providerTransportWrapper.setupTransportNotificationListener(); - this.#listener = apiTransport.onNotification( - this.#onTransportNotification.bind(this), - ); - return apiTransport; - } - } else if (transportType === TransportType.MWP) { - const { adapter: kvstore } = this.options.storage; - const dappClient = await this.#createDappClient(); - const { MWPTransport } = await import('./transports/mwp'); - const apiTransport = new MWPTransport(dappClient, kvstore); - this.#dappClient = dappClient; - this.#transport = apiTransport; - this.#transportType = TransportType.MWP; - this.#providerTransportWrapper.setupTransportNotificationListener(); - this.#listener = apiTransport.onNotification( - this.#onTransportNotification.bind(this), - ); - return apiTransport; + + if (transportType === TransportType.Browser) { + if (!hasExtensionInstalled) { + await this.storage.removeTransport(); + return undefined; } + const connection = DefaultConnection.create(); + await this.#swapConnection(connection); + return connection; + } - await this.storage.removeTransport(); + if (transportType === TransportType.MWP) { + const connection = await MwpConnection.create(this.#context()); + await this.#swapConnection(connection); + return connection; } + await this.storage.removeTransport(); return undefined; } async #setupTransport(): Promise { - const transport = await this.#getStoredTransport(); - if (transport) { - if (!this.transport.isConnected()) { + const connection = await this.#rehydrateConnection(); + if (connection) { + if (!connection.isConnected()) { this.status = 'connecting'; - await this.transport.connect(); + await connection.transport.connect(); } this.status = 'connected'; - if (this.#transportType === TransportType.MWP) { - await this.storage.setTransport(TransportType.MWP); - } else { - await this.storage.setTransport(TransportType.Browser); - } - } else { - this.status = 'loaded'; - const hasExtensionInstalled = await hasExtension(); - const preferExtension = this.options.ui.preferExtension ?? true; - // Setup passive listening for extension wallet_sessionChanged events - if (hasExtensionInstalled && preferExtension) { - await this.#setupDefaultTransport({ persist: false }); - // Normally calling DefaultTransport.connect() ensures that the transport is initialized - // and that wallet_sessionChanged (faked) is emitted. But because we are not - // calling transport.connect(), we need to initialize DefaultTransport manually. - try { - await this.transport.init(); - } catch (error) { - console.error('Passive init failed:', error); - } - } + await this.storage.setTransport(connection.type); + return; } - } - #buildConnectionMetadata(): ConnectionRequest['metadata'] { - const metadata: ConnectionRequest['metadata'] = { - dapp: this.options.dapp, - sdk: { version: getVersion(), platform: getPlatformType() }, - }; - if (this.#anonId) { - metadata.analytics = { remote_session_id: this.#anonId }; + this.status = 'loaded'; + const hasExtensionInstalled = await hasExtension(); + const preferExtension = this.options.ui.preferExtension ?? true; + // Setup passive listening for extension wallet_sessionChanged events + if (hasExtensionInstalled && preferExtension) { + const passive = DefaultConnection.create(); + await this.#swapConnection(passive); + // Normally calling DefaultTransport.connect() ensures that the transport is initialized + // and that wallet_sessionChanged (faked) is emitted. But because we are not + // calling transport.connect(), we need to initialize DefaultTransport manually. + await passive.initPassive(); } - return metadata; } async #init(): Promise { @@ -366,352 +386,38 @@ export class MetaMaskConnectMultichain extends MultichainCore { } } - async #createDappClient(): Promise { - const [mwpCore, { DappClient: DappClientClass }, { createKeyManager }] = - await Promise.all([ - import('@metamask/mobile-wallet-protocol-core'), - import('@metamask/mobile-wallet-protocol-dapp-client'), - import('./transports/mwp/KeyManager'), - ]); - const keymanager = await createKeyManager(); - - const { adapter: kvstore } = this.options.storage; - const sessionstore = await mwpCore.SessionStore.create(kvstore); - const websocket = - // eslint-disable-next-line no-negated-condition - typeof window !== 'undefined' - ? WebSocket - : (await import('ws')).WebSocket; - const transport = await mwpCore.WebSocketTransport.create({ - url: MWP_RELAY_URL, - kvstore, - websocket, - }); - const dappClient = new DappClientClass({ - transport, - sessionstore, - keymanager, - }); - return dappClient; - } - - async #setupMWP(): Promise { - if (this.#transportType === TransportType.MWP) { - return; - } - const { adapter: kvstore } = this.options.storage; - const dappClient = await this.#createDappClient(); - this.#dappClient = dappClient; - const { MWPTransport } = await import('./transports/mwp'); - const apiTransport = new MWPTransport(dappClient, kvstore); - this.#transport = apiTransport; - this.#transportType = TransportType.MWP; - this.#providerTransportWrapper.setupTransportNotificationListener(); - this.#listener = this.transport.onNotification( - this.#onTransportNotification.bind(this), - ); - await this.storage.setTransport(TransportType.MWP); - } - - async #onBeforeUnload(): Promise { - // Fixes glitch with "connecting" state when modal is still visible and we close screen or refresh - if (this.options.ui.factory.modal?.isMounted) { - await this.storage.removeTransport(); - } - } - - #createBeforeUnloadListener(): () => void { - const handler = this.#onBeforeUnload.bind(this); - - if ( - typeof window !== 'undefined' && - typeof window.addEventListener !== 'undefined' - ) { - window.addEventListener('beforeunload', handler); - } - return () => { - if ( - typeof window !== 'undefined' && - typeof window.removeEventListener !== 'undefined' - ) { - window.removeEventListener('beforeunload', handler); - } - }; - } - - async #renderInstallModalAsync( - desktopPreferred: boolean, - scopes: Scope[], - caipAccountIds: CaipAccountId[], - sessionProperties?: SessionProperties, - ): Promise { - return new Promise((resolve, reject) => { - // Use Connection Modal - this.options.ui.factory - .renderInstallModal( - desktopPreferred, - async () => { - if ( - this.dappClient.state === 'CONNECTED' || - this.dappClient.state === 'CONNECTING' - ) { - await this.dappClient.disconnect(); - } - return new Promise((_resolve) => { - this.dappClient.on( - 'session_request', - (sessionRequest: SessionRequest) => { - _resolve({ - sessionRequest, - metadata: this.#buildConnectionMetadata(), - }); - }, - ); - - (async (): Promise => { - try { - await this.transport.connect({ - scopes, - caipAccountIds, - sessionProperties, - }); - await this.options.ui.factory.unload(); - this.options.ui.factory.modal?.unmount(); - this.status = 'connected'; - await this.storage.setTransport(TransportType.MWP); - } catch (error) { - const { ProtocolError, ErrorCode } = await import( - '@metamask/mobile-wallet-protocol-core' - ); - if (error instanceof ProtocolError) { - if (error.code !== ErrorCode.REQUEST_EXPIRED) { - this.status = 'disconnected'; - // Close the modal on error - await this.options.ui.factory.unload(error); - reject(error); - } - // If request is expires, the QRCode will automatically be regenerated we can ignore this case - } else { - this.status = 'disconnected'; - const normalizedError = - error instanceof Error ? error : new Error(String(error)); - // Close the modal on error - await this.options.ui.factory.unload(normalizedError); - reject(normalizedError); - } - } - })().catch(() => { - // Error already handled in the async function - }); - }); - }, - async (error?: Error) => { - if (error) { - await this.storage.removeTransport(); - reject(error); - } else { - await this.storage.setTransport(TransportType.MWP); - resolve(); - } - }, - (uri: string) => { - this.emit('display_uri', uri); - }, - ) - .catch((error) => { - reject(error instanceof Error ? error : new Error(String(error))); - }); - }); - } - - async #showInstallModal( - desktopPreferred: boolean, - scopes: Scope[], - caipAccountIds: CaipAccountId[], - sessionProperties?: SessionProperties, - ): Promise { - // create the listener only once to avoid memory leaks - this.#beforeUnloadListener ??= this.#createBeforeUnloadListener(); - - // In headless mode, don't render UI but still emit display_uri events - if (this.options.ui.headless) { - await this.#headlessConnect(scopes, caipAccountIds, sessionProperties); - } else { - await this.#renderInstallModalAsync( - desktopPreferred, - scopes, - caipAccountIds, - sessionProperties, - ); - } - } - /** - * Handles connection in headless mode without rendering any UI. - * Emits display_uri events to allow consumers to build custom QR code UI. + * Ensure we have a `DefaultConnection` ready (creating one if necessary) + * and that storage records `Browser` as the active transport. * - * @param scopes - The requested permission scopes - * @param caipAccountIds - The requested account IDs - * @param sessionProperties - Optional session properties + * @returns The active `DefaultConnection`. */ - async #headlessConnect( - scopes: Scope[], - caipAccountIds: CaipAccountId[], - sessionProperties?: SessionProperties, - ): Promise { - return new Promise((resolve, reject) => { - if ( - this.dappClient.state === 'CONNECTED' || - this.dappClient.state === 'CONNECTING' - ) { - this.dappClient.disconnect().catch(() => { - // Ignore disconnect errors - }); - } - - // Listen for session_request to generate and emit the QR code link - this.dappClient.on( - 'session_request', - (sessionRequest: SessionRequest) => { - const connectionRequest: ConnectionRequest = { - sessionRequest, - metadata: this.#buildConnectionMetadata(), - }; - - // Generate and emit the QR code link - const deeplink = - this.options.ui.factory.createConnectionDeeplink(connectionRequest); - this.emit('display_uri', deeplink); - }, - ); - - // Start the connection - this.transport - .connect({ scopes, caipAccountIds, sessionProperties }) - .then(async () => { - this.status = 'connected'; - await this.storage.setTransport(TransportType.MWP); - resolve(); - }) - .catch(async (error) => { - const { ProtocolError } = await import( - '@metamask/mobile-wallet-protocol-core' - ); - if (error instanceof ProtocolError) { - // In headless mode, we don't auto-regenerate QR codes - // since there's no modal to display them - this.status = 'disconnected'; - await this.storage.removeTransport(); - reject(error); - } else { - this.status = 'disconnected'; - await this.storage.removeTransport(); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - }); - } - - async #setupDefaultTransport( - options: { persist?: boolean } = { persist: true }, - ): Promise { - if (this.#transportType === TransportType.Browser) { - return this.#transport as DefaultTransport; - } - - if (options?.persist) { + async #useDefaultConnection(): Promise { + if (this.#connection instanceof DefaultConnection) { await this.storage.setTransport(TransportType.Browser); + return this.#connection; } - const transport = new DefaultTransport(); - this.#listener = transport.onNotification( - this.#onTransportNotification.bind(this), - ); - this.#transport = transport; - this.#transportType = TransportType.Browser; - this.#providerTransportWrapper.setupTransportNotificationListener(); - return transport; + const connection = DefaultConnection.create(); + await this.#swapConnection(connection); + await this.storage.setTransport(TransportType.Browser); + return connection; } - async #deeplinkConnect( - scopes: Scope[], - caipAccountIds: CaipAccountId[], - sessionProperties?: SessionProperties, - ): Promise { - return new Promise(async (resolve, reject) => { - // Handle the response to the initial wallet_createSession request - const dappClientMessageHandler = (payload: unknown): void => { - if ( - typeof payload !== 'object' || - payload === null || - !('data' in payload) - ) { - return; - } - const data = payload.data as { result?: SessionData; error?: unknown }; - if (typeof data === 'object' && data !== null) { - // optimistically assume any error is due to the initial wallet_createSession request failure - if (data.error) { - this.dappClient.off('message', dappClientMessageHandler); - reject(data.error as Error); - } - // if sessionScopes is set in the result, then this is a response to wallet_createSession - if (data?.result?.sessionScopes) { - this.dappClient.off('message', dappClientMessageHandler); - // unsure if we need to call resolve here like we do above for reject() - } - } - }; - this.dappClient.on('message', dappClientMessageHandler); - - let timeout: NodeJS.Timeout | undefined; - - if (this.transport.isConnected()) { - timeout = setTimeout(() => { - this.openSimpleDeeplinkIfNeeded(); - }, 250); - } else { - this.dappClient.once( - 'session_request', - (sessionRequest: SessionRequest) => { - const connectionRequest = { - sessionRequest, - metadata: this.#buildConnectionMetadata(), - }; - const deeplink = - this.options.ui.factory.createConnectionDeeplink( - connectionRequest, - ); - const universalLink = - this.options.ui.factory.createConnectionUniversalLink( - connectionRequest, - ); - - // Emit display_uri event for deeplink connections - this.emit('display_uri', deeplink); - - if (this.options.mobile?.preferredOpenLink) { - this.options.mobile.preferredOpenLink(deeplink, '_self'); - } else { - openDeeplink(this.options, deeplink, universalLink); - } - }, - ); - } - - return this.transport - .connect({ scopes, caipAccountIds, sessionProperties }) - .then(resolve) - .catch(async (error) => { - await this.storage.removeTransport(); - this.dappClient.off('message', dappClientMessageHandler); - reject(error instanceof Error ? error : new Error(String(error))); - }) - .finally(() => { - if (timeout) { - clearTimeout(timeout); - } - }); - }); + /** + * Ensure we have an `MwpConnection` ready (creating one if necessary) + * and that storage records `MWP` as the active transport. + * + * @returns The active `MwpConnection`. + */ + async #useMwpConnection(): Promise { + if (this.#connection instanceof MwpConnection) { + await this.storage.setTransport(TransportType.MWP); + return this.#connection; + } + const connection = await MwpConnection.create(this.#context()); + await this.#swapConnection(connection); + await this.storage.setTransport(TransportType.MWP); + return connection; } async #handleConnection( @@ -775,9 +481,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { ): Promise { if ( this.status === 'connecting' && - this.#transportType === TransportType.MWP + this.#connection instanceof MwpConnection ) { - await this.#openConnectDeeplinkIfNeeded(); + await this.#connection.openConnectDeeplinkIfNeeded(); throw new Error( 'Existing connection is pending. Please check your MetaMask Mobile app to continue.', ); @@ -836,9 +542,13 @@ export class MetaMaskConnectMultichain extends MultichainCore { ? mergedSessionProperties : undefined; - if (this.#transport?.isConnected() && !secure) { + // Reuse an already-connected transport in a non-secure context. This + // path is taken when the user calls connect() again on an existing + // session to request additional scopes. + if (this.#connection?.isConnected() && !secure) { + const existing = this.#connection; return this.#handleConnection( - this.#transport + existing.transport .connect({ scopes: mergedScopes, caipAccountIds: mergedCaipAccountIds, @@ -846,10 +556,8 @@ export class MetaMaskConnectMultichain extends MultichainCore { forceRequest, }) .then(async () => { - if (this.#transportType === TransportType.MWP) { - return this.storage.setTransport(TransportType.MWP); - } - return this.storage.setTransport(TransportType.Browser); + await this.storage.setTransport(existing.type); + return undefined; }), scopes, transportType, @@ -858,9 +566,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { // In MetaMask Mobile In App Browser, window.ethereum is available directly if (platformType === PlatformType.MetaMaskMobileWebview) { - const defaultTransport = await this.#setupDefaultTransport(); + const connection = await this.#useDefaultConnection(); return this.#handleConnection( - defaultTransport.connect({ + connection.connect({ scopes: mergedScopes, caipAccountIds: mergedCaipAccountIds, sessionProperties: nonEmptySessionProperties, @@ -873,10 +581,10 @@ export class MetaMaskConnectMultichain extends MultichainCore { if (isWeb && hasExtensionInstalled && preferExtension) { // If metamask extension is available, connect to it - const defaultTransport = await this.#setupDefaultTransport(); + const connection = await this.#useDefaultConnection(); // Web transport has no initial payload return this.#handleConnection( - defaultTransport.connect({ + connection.connect({ scopes: mergedScopes, caipAccountIds: mergedCaipAccountIds, sessionProperties: nonEmptySessionProperties, @@ -888,7 +596,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { } // Connection will now be InstallModal + QRCodes or Deeplinks, both require mwp - await this.#setupMWP(); + const mwp = await this.#useMwpConnection(); // Determine preferred option for install modal const shouldShowInstallModal = hasExtensionInstalled @@ -898,24 +606,26 @@ export class MetaMaskConnectMultichain extends MultichainCore { if (secure && !shouldShowInstallModal) { // Desktop is not preferred option, so we use deeplinks (mobile web) return this.#handleConnection( - this.#deeplinkConnect( - mergedScopes, - mergedCaipAccountIds, - nonEmptySessionProperties, - ), + mwp.connect({ + flow: 'deeplink', + scopes: mergedScopes, + caipAccountIds: mergedCaipAccountIds, + sessionProperties: nonEmptySessionProperties, + }), scopes, transportType, ); } - // Show install modal for RN, Web + Node + // Show install modal for RN, Web + Node (or headless QR if requested) return this.#handleConnection( - this.#showInstallModal( - shouldShowInstallModal, - mergedScopes, - mergedCaipAccountIds, - nonEmptySessionProperties, - ), + mwp.connect({ + flow: this.options.ui.headless ? 'headless' : 'install-modal', + desktopPreferred: shouldShowInstallModal, + scopes: mergedScopes, + caipAccountIds: mergedCaipAccountIds, + sessionProperties: nonEmptySessionProperties, + }), scopes, transportType, ); @@ -931,9 +641,9 @@ export class MetaMaskConnectMultichain extends MultichainCore { sessionScopes: {}, sessionProperties: {}, }; - if (this.#transport?.isConnected()) { + if (this.#connection?.isConnected()) { try { - const response = await this.transport.request({ + const response = await this.#connection.transport.request({ method: 'wallet_getSession', }); if (response.result) { @@ -956,22 +666,18 @@ export class MetaMaskConnectMultichain extends MultichainCore { (scope) => !scopes.includes(scope as Scope), ); - await this.#transport?.disconnect(scopes); + await this.#connection?.disconnect(scopes); if (remainingScopes.length === 0) { await this.storage.removeTransport(); - // We want to leave the DefaultTransport instance connected so that we can - // still listen for wallet_sessionChanged events. - if (this.#transportType !== TransportType.Browser) { - await this.#listener?.(); - this.#beforeUnloadListener?.(); - this.#listener = undefined; - this.#beforeUnloadListener = undefined; - this.#transport = undefined; - this.#transportType = undefined; + // Keep the DefaultConnection instance alive so we can continue to + // listen for wallet_sessionChanged events from the extension. Only + // dispose non-Browser connections. + if (this.#connection && this.#connection.type !== TransportType.Browser) { + await this.#connection.dispose(); + this.#connection = undefined; this.#providerTransportWrapper.clearTransportNotificationListener(); - this.#dappClient = undefined; } this.status = 'disconnected'; @@ -986,7 +692,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { transport, rpcClient, options, - this.#transportType ?? TransportType.UNKNOWN, + this.#connection?.type ?? TransportType.UNKNOWN, ); // TODO: need read only method support for solana return requestRouter.invokeMethod(request); @@ -994,58 +700,8 @@ export class MetaMaskConnectMultichain extends MultichainCore { // DRY THIS WITH REQUEST ROUTER openSimpleDeeplinkIfNeeded(): void { - const { ui, mobile } = this.options; - const { showInstallModal = false } = ui ?? {}; - const secure = isSecure(); - const shouldOpenDeeplink = secure && !showInstallModal; - - if (shouldOpenDeeplink) { - setTimeout(async () => { - const session = await this.transport.getActiveSession(); - if (!session) { - throw new Error('No active session found'); - } - - const url = `${METAMASK_DEEPLINK_BASE}/mwp?id=${encodeURIComponent(session.id)}`; - if (mobile?.preferredOpenLink) { - mobile.preferredOpenLink(url, '_self'); - } else { - openDeeplink(this.options, url, METAMASK_CONNECT_BASE_URL); - } - }, 10); // small delay to ensure the message encryption and dispatch completes - } - } - - async #openConnectDeeplinkIfNeeded(): Promise { - const { ui } = this.options; - const { showInstallModal = false } = ui ?? {}; - const secure = isSecure(); - const shouldOpenDeeplink = secure && !showInstallModal; - - if (!shouldOpenDeeplink) { - return; - } - - const storedSessionRequest = - await this.#transport?.getStoredPendingSessionRequest(); - if (!storedSessionRequest) { - return; - } - - const connectionRequest = { - sessionRequest: storedSessionRequest, - metadata: this.#buildConnectionMetadata(), - }; - const deeplink = - this.options.ui.factory.createConnectionDeeplink(connectionRequest); - - const universalLink = - this.options.ui.factory.createConnectionUniversalLink(connectionRequest); - - if (this.options.mobile?.preferredOpenLink) { - this.options.mobile.preferredOpenLink(deeplink, '_self'); - } else { - openDeeplink(this.options, deeplink, universalLink); + if (this.#connection instanceof MwpConnection) { + this.#connection.openSimpleDeeplinkIfNeeded(); } } @@ -1055,7 +711,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { async emitSessionChanged(): Promise { const emptySession = { sessionScopes: {} }; - if (!this.#transport?.isConnected()) { + if (!this.#connection?.isConnected()) { // If we aren't connected or connecting, there definitely is no active CAIP session // so we optimistically emit an empty session to signify that to the ecosystem client consumers (EVM, Solana, etc.) this.emit('wallet_sessionChanged', emptySession); @@ -1063,7 +719,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { } // Otherwise, we need to fetch the current CAIP session from the wallet - const response = await this.transport.request({ + const response = await this.#connection.transport.request({ method: 'wallet_getSession', });