diff --git a/yarn-project/foundation/src/schemas/api.ts b/yarn-project/foundation/src/schemas/api.ts index 0231f0652587..2090999e363d 100644 --- a/yarn-project/foundation/src/schemas/api.ts +++ b/yarn-project/foundation/src/schemas/api.ts @@ -37,7 +37,10 @@ export type ApiSchema = { }; /** Return whether an API schema defines a valid function schema for a given method name. */ -export function schemaHasMethod(schema: ApiSchema, methodName: string) { +export function schemaHasMethod( + schema: T, + methodName: string, +): methodName is Extract { return ( typeof methodName === 'string' && Object.hasOwn(schema, methodName) && diff --git a/yarn-project/wallet-sdk/package.json b/yarn-project/wallet-sdk/package.json index b48c7af514f4..1c1f2927d540 100644 --- a/yarn-project/wallet-sdk/package.json +++ b/yarn-project/wallet-sdk/package.json @@ -7,6 +7,8 @@ "./base-wallet": "./dest/base-wallet/index.js", "./extension/handlers": "./dest/extension/handlers/index.js", "./extension/provider": "./dest/extension/provider/index.js", + "./iframe/handlers": "./dest/iframe/handlers/index.js", + "./iframe/provider": "./dest/iframe/provider/index.js", "./crypto": "./dest/crypto.js", "./types": "./dest/types.js", "./manager": "./dest/manager/index.js" @@ -16,6 +18,8 @@ "./src/base-wallet/index.ts", "./src/extension/handlers/index.ts", "./src/extension/provider/index.ts", + "./src/iframe/handlers/index.ts", + "./src/iframe/provider/index.ts", "./src/crypto.ts", "./src/types.ts", "./src/manager/index.ts" diff --git a/yarn-project/wallet-sdk/src/crypto.ts b/yarn-project/wallet-sdk/src/crypto.ts index 976628b50618..8b721b6f7a18 100644 --- a/yarn-project/wallet-sdk/src/crypto.ts +++ b/yarn-project/wallet-sdk/src/crypto.ts @@ -497,3 +497,107 @@ export function hashToEmoji(hash: string, count: number = DEFAULT_EMOJI_GRID_SIZ } return emojis.join(''); } + +// ─── Passphrase-based encryption (PBKDF2 + AES-256-GCM) ─────────────────── + +/** Default PBKDF2 iteration count. High to compensate for short PINs (~1-2s on modern hardware). */ +const DEFAULT_PBKDF2_ITERATIONS = 2_000_000; +const PBKDF2_SALT_BYTES = 16; +const PBKDF2_IV_BYTES = 12; + +/** + * Derives an AES-256-GCM key from a passphrase using PBKDF2-SHA256. + * + * @param passphrase - The user-provided passphrase or PIN + * @param salt - Random salt bytes + * @param iterations - PBKDF2 iteration count (default: 2,000,000) + * @returns An AES-256-GCM CryptoKey + */ +export async function deriveKeyFromPassphrase( + passphrase: string, + salt: Uint8Array, + iterations: number = DEFAULT_PBKDF2_ITERATIONS, +): Promise { + const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +/** + * Encrypts arbitrary bytes with a passphrase using PBKDF2 + AES-256-GCM. + * + * Output layout: `[salt (16)] [iv (12)] [ciphertext (...)]` + * + * @param plaintext - Data to encrypt + * @param passphrase - User passphrase or PIN + * @param iterations - PBKDF2 iteration count (default: 2,000,000) + * @returns A Uint8Array containing salt + iv + ciphertext + */ +export async function encryptWithPassphrase( + plaintext: Uint8Array, + passphrase: string, + iterations: number = DEFAULT_PBKDF2_ITERATIONS, +): Promise { + const salt = crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_BYTES)); + const iv = crypto.getRandomValues(new Uint8Array(PBKDF2_IV_BYTES)); + const key = await deriveKeyFromPassphrase(passphrase, salt, iterations); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext as BufferSource), + ); + const result = new Uint8Array(PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES + ciphertext.length); + result.set(salt, 0); + result.set(iv, PBKDF2_SALT_BYTES); + result.set(ciphertext, PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES); + return result; +} + +/** + * Decrypts data produced by {@link encryptWithPassphrase}. + * + * @param data - The encrypted blob (salt + iv + ciphertext) + * @param passphrase - The passphrase used during encryption + * @param iterations - PBKDF2 iteration count (must match encryption) + * @returns The decrypted plaintext bytes + * @throws On wrong passphrase (AES-GCM auth tag mismatch) + */ +export async function decryptWithPassphrase( + data: Uint8Array, + passphrase: string, + iterations: number = DEFAULT_PBKDF2_ITERATIONS, +): Promise { + const salt = data.slice(0, PBKDF2_SALT_BYTES); + const iv = data.slice(PBKDF2_SALT_BYTES, PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES); + const ciphertext = data.slice(PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES); + const key = await deriveKeyFromPassphrase(passphrase, salt, iterations); + return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext as BufferSource)); +} + +/** + * Converts a Uint8Array to a base64 string. + */ +export function uint8ToBase64(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) { + binary += String.fromCharCode(b); + } + return btoa(binary); +} + +/** + * Converts a base64 string to a Uint8Array. + */ +export function base64ToUint8(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/yarn-project/wallet-sdk/src/extension/provider/extension_wallet.ts b/yarn-project/wallet-sdk/src/extension/provider/extension_wallet.ts index b2b59db2635f..35cdbf4bea68 100644 --- a/yarn-project/wallet-sdk/src/extension/provider/extension_wallet.ts +++ b/yarn-project/wallet-sdk/src/extension/provider/extension_wallet.ts @@ -6,7 +6,7 @@ import { schemaHasMethod } from '@aztec/foundation/schemas'; import type { FunctionsOf } from '@aztec/foundation/types'; import { type EncryptedPayload, decrypt, encrypt } from '../../crypto.js'; -import { type WalletMessage, WalletMessageType, type WalletResponse } from '../../types.js'; +import { type DisconnectCallback, type WalletMessage, WalletMessageType, type WalletResponse } from '../../types.js'; /** * Internal type representing a wallet method call before encryption. @@ -19,11 +19,6 @@ type WalletMethodCall = { args: unknown[]; }; -/** - * Callback type for wallet disconnect events. - */ -export type DisconnectCallback = () => void; - /** * A wallet implementation that communicates with browser extension wallets * using an encrypted MessageChannel. diff --git a/yarn-project/wallet-sdk/src/extension/provider/index.ts b/yarn-project/wallet-sdk/src/extension/provider/index.ts index 9df529c0598b..a611e183477b 100644 --- a/yarn-project/wallet-sdk/src/extension/provider/index.ts +++ b/yarn-project/wallet-sdk/src/extension/provider/index.ts @@ -1,4 +1,4 @@ -export { ExtensionWallet, type DisconnectCallback } from './extension_wallet.js'; +export { ExtensionWallet } from './extension_wallet.js'; export { ExtensionProvider, type DiscoveredWallet, diff --git a/yarn-project/wallet-sdk/src/iframe/handlers/iframe_connection_handler.ts b/yarn-project/wallet-sdk/src/iframe/handlers/iframe_connection_handler.ts new file mode 100644 index 000000000000..5605206fa985 --- /dev/null +++ b/yarn-project/wallet-sdk/src/iframe/handlers/iframe_connection_handler.ts @@ -0,0 +1,328 @@ +/** + * IframeConnectionHandler — wallet-side of the cross-origin iframe protocol. + * + * This mirrors {@link BackgroundConnectionHandler} from `@aztec/wallet-sdk/extension/handlers` + * but uses `window.postMessage` instead of browser.runtime messaging. + * + * Message flow (wallet receives): + * parent → DISCOVERY → show approval UI → send DISCOVERY_RESPONSE + * parent → KEY_EXCHANGE_REQUEST → ECDH → send KEY_EXCHANGE_RESPONSE + * parent → SECURE_MESSAGE → decrypt → Wallet → encrypt → SECURE_RESPONSE + * parent → DISCONNECT → terminate session + * + * The wallet announces itself by posting WALLET_READY as soon as the handler starts, + * so the dApp knows it can send a discovery request. + */ +import type { ChainInfo } from '@aztec/aztec.js/account'; +import { createLogger } from '@aztec/aztec.js/log'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { WalletSchema } from '@aztec/aztec.js/wallet'; +import { jsonStringify } from '@aztec/foundation/json-rpc'; +import { parseWithOptionals, schemaHasMethod } from '@aztec/foundation/schemas'; + +import { + type EncryptedPayload, + decrypt, + deriveSessionKeys, + encrypt, + exportPublicKey, + generateKeyPair, + importPublicKey, +} from '../../crypto.js'; +import { type WalletMessage, WalletMessageType, type WalletResponse } from '../../types.js'; + +/** + * A pending discovery request from a dApp (before user approval). + */ +export interface PendingSession { + /** Unique request identifier */ + requestId: string; + /** Application identifier */ + appId: string; + /** Origin URL of the requesting page */ + origin: string; + /** Approval status */ + status: 'pending' | 'approved'; +} + +/** + * An active session (after key exchange). + */ +export interface ActiveSession { + /** Session identifier (same as the discovery requestId) */ + sessionId: string; + /** AES-256-GCM shared key for this session */ + sharedKey: CryptoKey; + /** Verification hash for emoji display */ + verificationHash: string; + /** Origin URL of the connected dApp */ + origin: string; + /** Application identifier */ + appId: string; +} + +/** + * Configuration for the iframe connection handler. + */ +export interface IframeConnectionConfig { + /** Unique wallet identifier */ + walletId: string; + /** Display name for the wallet */ + walletName: string; + /** Wallet version string */ + walletVersion: string; + /** Optional wallet icon URL */ + walletIcon?: string; + /** Origins allowed to connect. If empty or undefined, all origins are allowed (dev mode). */ + allowedOrigins?: string[]; +} + +/** + * Event callbacks for the iframe connection handler. + */ +export interface IframeConnectionCallbacks { + /** Called when a new discovery request arrives — wallet can show approval UI */ + onPendingDiscovery?: (session: PendingSession) => void; + /** Called when a session is established (key exchange complete) */ + onSessionEstablished?: (session: ActiveSession) => void; + /** Called when a session is terminated */ + onSessionTerminated?: (sessionId: string) => void; + /** Called when a key exchange completes — show verificationHash as emojis to the user */ + onVerificationHash?: (verificationHash: string) => void; + /** + * Resolves the Wallet instance to use for a given dApp and chain. + * Called when an encrypted message arrives and needs to be dispatched. + */ + getWallet: (appId: string, chainInfo: ChainInfo) => Promise; +} + +/** + * Handles the wallet side of the cross-origin iframe protocol. + * + * Manages the full lifecycle: discovery, ECDH key exchange, encrypted message + * dispatch to a {@link Wallet} instance, and session termination. + * + * @example + * ```typescript + * const handler = new IframeConnectionHandler( + * { walletId: 'my-wallet', walletName: 'My Wallet', walletVersion: '1.0.0' }, + * { + * onPendingDiscovery: (session) => showApprovalUI(session), + * getWallet: (appId, chainInfo) => createWalletForApp(appId, chainInfo), + * }, + * ); + * handler.start(); + * ``` + */ +export class IframeConnectionHandler { + private pendingSessions = new Map(); + private activeSessions = new Map(); + private log = createLogger('wallet:iframe-handler'); + + constructor( + private config: IframeConnectionConfig, + private callbacks: IframeConnectionCallbacks, + ) {} + + start(): void { + window.addEventListener('message', this.handleMessage); + this.postToParent({ type: WalletMessageType.WALLET_READY }); + this.log.info('IframeConnectionHandler started, posted WALLET_READY'); + } + + stop(): void { + window.removeEventListener('message', this.handleMessage); + } + + approveDiscovery(requestId: string): void { + const pending = this.pendingSessions.get(requestId); + if (!pending || pending.status !== 'pending') { + return; + } + + pending.status = 'approved'; + this.postToOrigin(pending.origin, { + type: WalletMessageType.DISCOVERY_RESPONSE, + requestId, + walletInfo: { + id: this.config.walletId, + name: this.config.walletName, + version: this.config.walletVersion, + icon: this.config.walletIcon, + }, + }); + this.log.info(`Discovery approved for requestId=${requestId}`); + } + + rejectDiscovery(requestId: string): void { + this.pendingSessions.delete(requestId); + } + + terminateSession(sessionId: string): void { + const session = this.activeSessions.get(sessionId); + if (session) { + this.postToOrigin(session.origin, { + type: WalletMessageType.SESSION_DISCONNECTED, + sessionId, + }); + this.activeSessions.delete(sessionId); + this.callbacks.onSessionTerminated?.(sessionId); + } + } + + getPendingSessions(): PendingSession[] { + return Array.from(this.pendingSessions.values()).filter(s => s.status === 'pending'); + } + + private handleMessage = (event: MessageEvent): void => { + void this.handleMessageAsync(event); + }; + + private async handleMessageAsync(event: MessageEvent): Promise { + if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) { + if (!this.config.allowedOrigins.includes(event.origin)) { + return; + } + } + + const msg = event.data; + if (!msg || typeof msg !== 'object' || !msg.type) { + return; + } + + switch (msg.type) { + case WalletMessageType.DISCOVERY: + this.handleDiscoveryRequest(msg, event.origin); + break; + case WalletMessageType.KEY_EXCHANGE_REQUEST: + await this.handleKeyExchangeRequest(msg, event.origin); + break; + case WalletMessageType.SECURE_MESSAGE: + await this.handleSecureMessage(msg); + break; + case WalletMessageType.DISCONNECT: + this.terminateSession(msg.sessionId); + break; + } + } + + private handleDiscoveryRequest(msg: Record, origin: string): void { + // eslint-disable-next-line jsdoc/require-jsdoc + const { requestId, appId } = msg as { requestId: string; appId: string }; + const pending: PendingSession = { requestId, appId, origin, status: 'pending' }; + this.pendingSessions.set(requestId, pending); + this.log.info(`Discovery request from appId=${appId} origin=${origin}`); + this.callbacks.onPendingDiscovery?.(pending); + } + + private async handleKeyExchangeRequest(msg: Record, origin: string): Promise { + const { requestId, publicKey: appPublicKeyRaw } = msg as { + // eslint-disable-next-line jsdoc/require-jsdoc + requestId: string; + // eslint-disable-next-line jsdoc/require-jsdoc + publicKey: { kty: string; crv: string; x: string; y: string }; + }; + const pending = this.pendingSessions.get(requestId); + if (!pending || pending.status !== 'approved') { + this.log.warn(`Key exchange for unknown/unapproved requestId=${requestId}`); + return; + } + + try { + const keyPair = await generateKeyPair(); + const walletPublicKey = await exportPublicKey(keyPair.publicKey); + const appPublicKey = await importPublicKey(appPublicKeyRaw); + const sessionKeys = await deriveSessionKeys(keyPair, appPublicKey, false); + + const session: ActiveSession = { + sessionId: requestId, + sharedKey: sessionKeys.encryptionKey, + verificationHash: sessionKeys.verificationHash, + origin: pending.origin, + appId: pending.appId, + }; + + this.activeSessions.set(requestId, session); + this.pendingSessions.delete(requestId); + + this.postToOrigin(origin, { + type: WalletMessageType.KEY_EXCHANGE_RESPONSE, + requestId, + publicKey: walletPublicKey, + verificationHash: sessionKeys.verificationHash, + }); + + this.callbacks.onVerificationHash?.(sessionKeys.verificationHash); + this.callbacks.onSessionEstablished?.(session); + this.log.info(`Key exchange complete, sessionId=${requestId}`); + } catch (err) { + this.log.error(`Key exchange failed: ${err}`); + } + } + + private async handleSecureMessage(msg: Record): Promise { + // eslint-disable-next-line jsdoc/require-jsdoc + const { sessionId, encrypted } = msg as { sessionId: string; encrypted: EncryptedPayload }; + const session = this.activeSessions.get(sessionId); + if (!session) { + return; + } + + let walletMessage: WalletMessage; + try { + walletMessage = await decrypt(session.sharedKey, encrypted); + } catch { + this.log.warn(`Decryption failed for sessionId=${sessionId}`); + return; + } + + const { messageId, type, args, chainInfo, appId } = walletMessage; + + let result: unknown; + let error: string | undefined; + + try { + const wallet = await this.callbacks.getWallet(appId, chainInfo); + + if (!schemaHasMethod(WalletSchema, type)) { + throw new Error(`Unknown wallet method: ${type}`); + } + // Zod's AnyZodTuple rejects optional tuple items typed as `T | undefined` + const sanitizedArgs = await parseWithOptionals(args, WalletSchema[type].parameters() as any); + result = await (wallet as Record Promise>)[type](...sanitizedArgs); + } catch (err: unknown) { + error = err instanceof Error ? err.message : String(err); + this.log.error(`Error handling ${type}: ${error}`); + } + + const response: WalletResponse = { + messageId, + walletId: this.config.walletId, + result, + error, + }; + + try { + const encryptedResponse = await encrypt(session.sharedKey, jsonStringify(response)); + this.postToOrigin(session.origin, { + type: WalletMessageType.SECURE_RESPONSE, + sessionId, + encrypted: encryptedResponse, + }); + } catch (err) { + this.log.error(`Encryption of response failed: ${err}`); + } + } + + private postToParent(msg: object): void { + if (window.parent !== window) { + window.parent.postMessage(msg, '*'); + } + } + + private postToOrigin(origin: string, msg: object): void { + if (window.parent !== window) { + window.parent.postMessage(msg, origin); + } + } +} diff --git a/yarn-project/wallet-sdk/src/iframe/handlers/index.ts b/yarn-project/wallet-sdk/src/iframe/handlers/index.ts new file mode 100644 index 000000000000..8207ad9d58ca --- /dev/null +++ b/yarn-project/wallet-sdk/src/iframe/handlers/index.ts @@ -0,0 +1,7 @@ +export { + IframeConnectionHandler, + type IframeConnectionConfig, + type IframeConnectionCallbacks, + type PendingSession, + type ActiveSession, +} from './iframe_connection_handler.js'; diff --git a/yarn-project/wallet-sdk/src/iframe/provider/iframe_discovery.ts b/yarn-project/wallet-sdk/src/iframe/provider/iframe_discovery.ts new file mode 100644 index 000000000000..e81684861e0e --- /dev/null +++ b/yarn-project/wallet-sdk/src/iframe/provider/iframe_discovery.ts @@ -0,0 +1,185 @@ +/** + * Web wallet discovery — creates {@link IframeWalletProvider} instances from a list of URLs. + * + * For each configured URL we probe the wallet by loading a tiny invisible iframe, + * waiting for WALLET_READY, then sending a DISCOVERY request. On a successful + * DISCOVERY_RESPONSE we emit an IframeWalletProvider to the caller. + * + * This is intentionally lightweight (no key exchange yet) — key exchange happens + * later when the user selects the wallet and calls `provider.establishSecureChannel()`. + */ +import type { ChainInfo } from '@aztec/aztec.js/account'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; + +import type { DiscoverySession, WalletProvider } from '../../manager/types.js'; +import { type WalletInfo, WalletMessageType } from '../../types.js'; +import { IframeWalletProvider } from './iframe_provider.js'; + +const PROBE_TIMEOUT_MS = 10_000; + +/** + * Probes a list of web wallet URLs and returns a {@link DiscoverySession} compatible + * with WalletManager's `getAvailableWallets()` interface. + * + * Discovered {@link IframeWalletProvider} instances are yielded asynchronously as each + * wallet responds to the probe. + * + * @param walletUrls - URLs of web wallets to probe + * @param chainInfo - Network information to pass during discovery + * @returns A cancellable discovery session + */ +export function discoverWebWallets(walletUrls: string[], chainInfo: ChainInfo): DiscoverySession { + const { promise: donePromise, resolve: resolveDone } = promiseWithResolvers(); + + /* eslint-disable jsdoc/require-jsdoc */ + type IteratorState = + | { status: 'discovering'; resolve: ((result: IteratorResult) => void) | null } + | { status: 'done' }; + /* eslint-enable jsdoc/require-jsdoc */ + + let state: IteratorState = { status: 'discovering', resolve: null }; + const pendingProviders: WalletProvider[] = []; + + // eslint-disable-next-line jsdoc/require-jsdoc + function emit(provider: WalletProvider) { + if (state.status !== 'discovering') { + return; + } + if (state.resolve) { + const resolve = state.resolve; + state.resolve = null; + resolve({ value: provider, done: false }); + } else { + pendingProviders.push(provider); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + function markComplete() { + if (state.status !== 'discovering') { + return; + } + const pendingResolve = state.resolve; + state = { status: 'done' }; + resolveDone(); + if (pendingResolve) { + pendingResolve({ value: undefined as unknown as WalletProvider, done: true }); + } + } + + // Probe all URLs in parallel + const probes = walletUrls.map(url => + probeWallet(url, chainInfo, PROBE_TIMEOUT_MS).then( + provider => { + if (provider) { + emit(provider); + } + }, + () => { + // ignore probe errors + }, + ), + ); + + void Promise.all(probes).then(markComplete); + + const wallets: AsyncIterable = { + // eslint-disable-next-line jsdoc/require-jsdoc + [Symbol.asyncIterator](): AsyncIterator { + return { + // eslint-disable-next-line jsdoc/require-jsdoc + next(): Promise> { + if (pendingProviders.length > 0) { + return Promise.resolve({ value: pendingProviders.shift()!, done: false }); + } + if (state.status === 'done') { + return Promise.resolve({ value: undefined as unknown as WalletProvider, done: true }); + } + return new Promise(resolve => { + if (state.status === 'discovering') { + state.resolve = resolve; + } + }); + }, + // eslint-disable-next-line jsdoc/require-jsdoc + return(): Promise> { + markComplete(); + return Promise.resolve({ value: undefined as unknown as WalletProvider, done: true }); + }, + }; + }, + }; + + return { + wallets, + done: donePromise, + cancel: markComplete, + }; +} + +/** + * Probes a single web wallet URL. + * Creates a temporary hidden iframe, waits for WALLET_READY, sends DISCOVERY_REQUEST. + * Returns an IframeWalletProvider on success, null on timeout/failure. + * @internal + */ +function probeWallet(walletUrl: string, chainInfo: ChainInfo, timeoutMs: number): Promise { + const walletOrigin = new URL(walletUrl).origin; + const iframe = document.createElement('iframe'); + iframe.src = walletUrl; + iframe.style.cssText = 'display:none;width:0;height:0;border:none;position:absolute;top:-9999px;'; + iframe.allow = 'storage-access; cross-origin-isolated'; + let timer: ReturnType; + + // Register listener BEFORE appending to DOM to avoid race with WALLET_READY + const result = new Promise(resolve => { + const cleanup = () => { + if (iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } + window.removeEventListener('message', handler); + clearTimeout(timer); + }; + + timer = setTimeout(() => { + cleanup(); + resolve(null); + }, timeoutMs); + + let step: 'waiting-ready' | 'waiting-discovery' = 'waiting-ready'; + const requestId = globalThis.crypto.randomUUID(); + + // eslint-disable-next-line jsdoc/require-jsdoc + function handler(event: MessageEvent) { + if (event.origin !== walletOrigin) { + return; + } + const msg = event.data; + if (!msg || typeof msg !== 'object') { + return; + } + + if (step === 'waiting-ready' && msg.type === WalletMessageType.WALLET_READY) { + step = 'waiting-discovery'; + iframe.contentWindow?.postMessage( + { type: WalletMessageType.DISCOVERY, requestId, appId: 'discovery-probe' }, + walletOrigin, + ); + } else if ( + step === 'waiting-discovery' && + msg.type === WalletMessageType.DISCOVERY_RESPONSE && + msg.requestId === requestId + ) { + const info = msg.walletInfo as WalletInfo; + cleanup(); + resolve(new IframeWalletProvider(info.id, info.name, info.icon, walletUrl, chainInfo)); + } + } + + window.addEventListener('message', handler); + }); + + document.body.appendChild(iframe); + + return result; +} diff --git a/yarn-project/wallet-sdk/src/iframe/provider/iframe_provider.ts b/yarn-project/wallet-sdk/src/iframe/provider/iframe_provider.ts new file mode 100644 index 000000000000..ddddec2e339d --- /dev/null +++ b/yarn-project/wallet-sdk/src/iframe/provider/iframe_provider.ts @@ -0,0 +1,331 @@ +/** + * IframeWalletProvider — implements {@link WalletProvider} for web wallets loaded in iframes. + * + * Flow (mirrors ExtensionProvider): + * 1. Creates an `