@aztec/wallet-sdk 0.0.1-commit.b6e433891 → 0.0.1-commit.b9865e97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +125 -0
  2. package/dest/base-wallet/base_wallet.d.ts +51 -40
  3. package/dest/base-wallet/base_wallet.d.ts.map +1 -1
  4. package/dest/base-wallet/base_wallet.js +147 -75
  5. package/dest/base-wallet/index.d.ts +2 -2
  6. package/dest/base-wallet/index.d.ts.map +1 -1
  7. package/dest/base-wallet/utils.d.ts +7 -4
  8. package/dest/base-wallet/utils.d.ts.map +1 -1
  9. package/dest/base-wallet/utils.js +11 -5
  10. package/dest/crypto.d.ts +39 -1
  11. package/dest/crypto.d.ts.map +1 -1
  12. package/dest/crypto.js +88 -0
  13. package/dest/extension/handlers/background_connection_handler.d.ts +12 -2
  14. package/dest/extension/handlers/background_connection_handler.d.ts.map +1 -1
  15. package/dest/extension/handlers/background_connection_handler.js +44 -8
  16. package/dest/extension/handlers/content_script_connection_handler.d.ts +2 -1
  17. package/dest/extension/handlers/content_script_connection_handler.d.ts.map +1 -1
  18. package/dest/extension/handlers/content_script_connection_handler.js +19 -0
  19. package/dest/extension/handlers/internal_message_types.d.ts +3 -1
  20. package/dest/extension/handlers/internal_message_types.d.ts.map +1 -1
  21. package/dest/extension/handlers/internal_message_types.js +3 -1
  22. package/dest/extension/provider/extension_wallet.d.ts +26 -6
  23. package/dest/extension/provider/extension_wallet.d.ts.map +1 -1
  24. package/dest/extension/provider/extension_wallet.js +80 -9
  25. package/dest/extension/provider/index.d.ts +2 -2
  26. package/dest/extension/provider/index.d.ts.map +1 -1
  27. package/dest/iframe/handlers/iframe_connection_handler.d.ts +122 -0
  28. package/dest/iframe/handlers/iframe_connection_handler.d.ts.map +1 -0
  29. package/dest/iframe/handlers/iframe_connection_handler.js +239 -0
  30. package/dest/iframe/handlers/index.d.ts +2 -0
  31. package/dest/iframe/handlers/index.d.ts.map +1 -0
  32. package/dest/iframe/handlers/index.js +1 -0
  33. package/dest/iframe/provider/iframe_discovery.d.ts +25 -0
  34. package/dest/iframe/provider/iframe_discovery.d.ts.map +1 -0
  35. package/dest/iframe/provider/iframe_discovery.js +167 -0
  36. package/dest/iframe/provider/iframe_provider.d.ts +65 -0
  37. package/dest/iframe/provider/iframe_provider.d.ts.map +1 -0
  38. package/dest/iframe/provider/iframe_provider.js +257 -0
  39. package/dest/iframe/provider/iframe_wallet.d.ts +85 -0
  40. package/dest/iframe/provider/iframe_wallet.d.ts.map +1 -0
  41. package/dest/iframe/provider/iframe_wallet.js +269 -0
  42. package/dest/iframe/provider/index.d.ts +4 -0
  43. package/dest/iframe/provider/index.d.ts.map +1 -0
  44. package/dest/iframe/provider/index.js +3 -0
  45. package/dest/manager/types.d.ts +3 -2
  46. package/dest/manager/types.d.ts.map +1 -1
  47. package/dest/manager/wallet_manager.d.ts +1 -1
  48. package/dest/manager/wallet_manager.d.ts.map +1 -1
  49. package/dest/manager/wallet_manager.js +46 -16
  50. package/dest/types.d.ts +64 -2
  51. package/dest/types.d.ts.map +1 -1
  52. package/dest/types.js +29 -0
  53. package/package.json +12 -8
  54. package/src/base-wallet/base_wallet.ts +212 -121
  55. package/src/base-wallet/index.ts +6 -1
  56. package/src/base-wallet/utils.ts +15 -5
  57. package/src/crypto.ts +104 -0
  58. package/src/extension/handlers/background_connection_handler.ts +42 -9
  59. package/src/extension/handlers/content_script_connection_handler.ts +18 -0
  60. package/src/extension/handlers/internal_message_types.ts +2 -0
  61. package/src/extension/provider/extension_wallet.ts +94 -13
  62. package/src/extension/provider/index.ts +1 -1
  63. package/src/iframe/handlers/iframe_connection_handler.ts +341 -0
  64. package/src/iframe/handlers/index.ts +7 -0
  65. package/src/iframe/provider/iframe_discovery.ts +185 -0
  66. package/src/iframe/provider/iframe_provider.ts +331 -0
  67. package/src/iframe/provider/iframe_wallet.ts +323 -0
  68. package/src/iframe/provider/index.ts +3 -0
  69. package/src/manager/types.ts +2 -1
  70. package/src/manager/wallet_manager.ts +48 -14
  71. package/src/types.ts +72 -0
@@ -0,0 +1,331 @@
1
+ /**
2
+ * IframeWalletProvider — implements {@link WalletProvider} for web wallets loaded in iframes.
3
+ *
4
+ * Flow (mirrors ExtensionProvider):
5
+ * 1. Creates an `<iframe src="walletUrl">` (in app-provided container or floating panel)
6
+ * 2. Waits for WALLET_READY message from the iframe
7
+ * 3. Sends DISCOVERY → waits for DISCOVERY_RESPONSE
8
+ * 4. Sends KEY_EXCHANGE_REQUEST (ECDH public key) → waits for KEY_EXCHANGE_RESPONSE
9
+ * 5. Derives shared session keys, exposes verificationHash
10
+ * 6. On confirm(): returns IframeWallet backed by the established session
11
+ */
12
+ import type { ChainInfo } from '@aztec/aztec.js/account';
13
+ import type { Wallet } from '@aztec/aztec.js/wallet';
14
+
15
+ import {
16
+ type ExportedPublicKey,
17
+ deriveSessionKeys,
18
+ exportPublicKey,
19
+ generateKeyPair,
20
+ importPublicKey,
21
+ } from '../../crypto.js';
22
+ import type { PendingConnection, ProviderDisconnectionCallback, WalletProvider } from '../../manager/types.js';
23
+ import type { WalletInfo } from '../../types.js';
24
+ import { WalletMessageType } from '../../types.js';
25
+ import { IframeWallet } from './iframe_wallet.js';
26
+
27
+ const READY_TIMEOUT_MS = 15_000;
28
+ const DISCOVERY_TIMEOUT_MS = 15_000;
29
+ const KEY_EXCHANGE_TIMEOUT_MS = 15_000;
30
+
31
+ /**
32
+ * Options for {@link IframeWalletProvider.establishSecureChannel}.
33
+ */
34
+ export interface IframeConnectionOptions {
35
+ /** Container element to inject the iframe into. If omitted, a floating panel is created. */
36
+ container?: HTMLElement;
37
+ }
38
+
39
+ /**
40
+ * A {@link WalletProvider} that connects to a web wallet loaded in a cross-origin iframe.
41
+ *
42
+ * Discovered via {@link discoverWebWallets} and used through the standard
43
+ * `WalletProvider.establishSecureChannel()` flow.
44
+ */
45
+ export class IframeWalletProvider implements WalletProvider {
46
+ readonly type = 'web' as const;
47
+
48
+ private iframe: HTMLIFrameElement | null = null;
49
+ private _container: HTMLDivElement | null = null;
50
+ private _appOwnsContainer = false;
51
+ private _dragCleanup: (() => void) | null = null;
52
+ private wallet: IframeWallet | null = null;
53
+ private _disconnected = false;
54
+ private disconnectCallbacks: ProviderDisconnectionCallback[] = [];
55
+
56
+ constructor(
57
+ /** Unique wallet identifier. */
58
+ public readonly id: string,
59
+ /** Display name for the wallet. */
60
+ public readonly name: string,
61
+ /** Optional wallet icon URL. */
62
+ public readonly icon: string | undefined,
63
+ private readonly walletUrl: string,
64
+ private readonly chainInfo: ChainInfo,
65
+ ) {}
66
+
67
+ /**
68
+ * Establishes a secure encrypted channel with the iframe wallet.
69
+ *
70
+ * @param appId - Application identifier for the requesting dApp
71
+ * @param options - Optional container element for the iframe
72
+ * @returns A {@link PendingConnection} with verification hash and confirm/cancel
73
+ */
74
+ async establishSecureChannel(appId: string, options?: IframeConnectionOptions): Promise<PendingConnection> {
75
+ const iframe = document.createElement('iframe');
76
+ iframe.src = this.walletUrl;
77
+ iframe.style.cssText = 'flex:1;border:none;width:100%;height:100%;display:block;';
78
+ iframe.allow = 'storage-access; cross-origin-isolated';
79
+ this.iframe = iframe;
80
+
81
+ if (options?.container) {
82
+ this._appOwnsContainer = true;
83
+ options.container.appendChild(iframe);
84
+ } else {
85
+ this.createFloatingPanel(iframe);
86
+ }
87
+
88
+ const walletOrigin = new URL(this.walletUrl).origin;
89
+
90
+ const post = (msg: object) => {
91
+ if (!iframe.contentWindow) {
92
+ throw new Error('Iframe not ready');
93
+ }
94
+ iframe.contentWindow.postMessage(msg, walletOrigin);
95
+ };
96
+
97
+ await waitForMessage(msg => msg.type === WalletMessageType.WALLET_READY, READY_TIMEOUT_MS, walletOrigin);
98
+
99
+ const requestId = globalThis.crypto.randomUUID();
100
+ post({ type: WalletMessageType.DISCOVERY, requestId, appId });
101
+
102
+ const discoveryResp = await waitForMessage(
103
+ msg => msg.type === WalletMessageType.DISCOVERY_RESPONSE && msg.requestId === requestId,
104
+ DISCOVERY_TIMEOUT_MS,
105
+ walletOrigin,
106
+ );
107
+
108
+ const walletInfo = discoveryResp.walletInfo as WalletInfo;
109
+
110
+ const keyPair = await generateKeyPair();
111
+ const dAppPublicKey = await exportPublicKey(keyPair.publicKey);
112
+ post({ type: WalletMessageType.KEY_EXCHANGE_REQUEST, requestId, publicKey: dAppPublicKey });
113
+
114
+ const keyExchangeResp = await waitForMessage(
115
+ msg => msg.type === WalletMessageType.KEY_EXCHANGE_RESPONSE && msg.requestId === requestId,
116
+ KEY_EXCHANGE_TIMEOUT_MS,
117
+ walletOrigin,
118
+ );
119
+
120
+ const walletPublicKey = await importPublicKey(keyExchangeResp.publicKey as ExportedPublicKey);
121
+ const sessionKeys = await deriveSessionKeys(keyPair, walletPublicKey, true);
122
+ const { verificationHash, encryptionKey: sharedKey } = sessionKeys;
123
+
124
+ const iframeWallet = IframeWallet.create(
125
+ walletInfo.id,
126
+ requestId, // sessionId
127
+ iframe.contentWindow!,
128
+ walletOrigin,
129
+ sharedKey,
130
+ this.chainInfo,
131
+ appId,
132
+ );
133
+
134
+ this.wallet = iframeWallet;
135
+
136
+ iframeWallet.onDisconnect(() => {
137
+ this._disconnected = true;
138
+ for (const cb of this.disconnectCallbacks) {
139
+ try {
140
+ cb();
141
+ } catch {
142
+ // ignore
143
+ }
144
+ }
145
+ });
146
+
147
+ let cancelled = false;
148
+
149
+ return {
150
+ verificationHash,
151
+ confirm: (): Promise<Wallet> => {
152
+ if (cancelled) {
153
+ throw new Error('Connection was cancelled');
154
+ }
155
+ return Promise.resolve(iframeWallet.asWallet());
156
+ },
157
+ cancel: () => {
158
+ cancelled = true;
159
+ this.cleanup();
160
+ },
161
+ };
162
+ }
163
+
164
+ disconnect(): Promise<void> {
165
+ if (this.wallet && !this.wallet.isDisconnected()) {
166
+ this.wallet.disconnect();
167
+ }
168
+ this.cleanup();
169
+ return Promise.resolve();
170
+ }
171
+
172
+ onDisconnect(callback: ProviderDisconnectionCallback): () => void {
173
+ this.disconnectCallbacks.push(callback);
174
+ return () => {
175
+ const i = this.disconnectCallbacks.indexOf(callback);
176
+ if (i !== -1) {
177
+ this.disconnectCallbacks.splice(i, 1);
178
+ }
179
+ };
180
+ }
181
+
182
+ isDisconnected(): boolean {
183
+ return this._disconnected;
184
+ }
185
+
186
+ // ── Floating panel creation ─────────────────────────────────────────────────
187
+
188
+ private createFloatingPanel(iframe: HTMLIFrameElement): void {
189
+ const W = 420,
190
+ H = 500;
191
+ const initLeft = window.innerWidth - W - 24;
192
+ const initTop = window.innerHeight - H - 24;
193
+
194
+ const container = document.createElement('div');
195
+ container.style.cssText = `
196
+ position:fixed;left:${initLeft}px;top:${initTop}px;width:${W}px;height:${H}px;
197
+ border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.4);z-index:999999;
198
+ overflow:hidden;display:flex;flex-direction:column;user-select:none;
199
+ `;
200
+
201
+ const dragHandle = document.createElement('div');
202
+ dragHandle.style.cssText = `
203
+ height:28px;min-height:28px;background:rgba(30,30,30,0.95);cursor:grab;
204
+ display:flex;align-items:center;justify-content:center;
205
+ border-bottom:1px solid rgba(255,255,255,0.08);flex-shrink:0;
206
+ `;
207
+ dragHandle.innerHTML = `<span style="color:rgba(255,255,255,0.3);font-size:14px;letter-spacing:4px">&#8942;&#8942;&#8942;</span>`;
208
+
209
+ const resizeHandle = document.createElement('div');
210
+ resizeHandle.style.cssText =
211
+ 'position:absolute;bottom:0;right:0;width:16px;height:16px;cursor:se-resize;z-index:1;';
212
+ resizeHandle.innerHTML = `<svg width="16" height="16" style="opacity:0.3;display:block"><path d="M2 14 L14 2 M6 14 L14 6 M10 14 L14 10" stroke="white" stroke-width="1.5"/></svg>`;
213
+
214
+ container.appendChild(dragHandle);
215
+ container.appendChild(iframe);
216
+ container.appendChild(resizeHandle);
217
+ document.body.appendChild(container);
218
+ this._container = container;
219
+
220
+ let dragging = false;
221
+ let dragOffsetX = 0,
222
+ dragOffsetY = 0;
223
+
224
+ dragHandle.addEventListener('mousedown', (e: MouseEvent) => {
225
+ dragging = true;
226
+ dragHandle.style.cursor = 'grabbing';
227
+ const rect = container.getBoundingClientRect();
228
+ dragOffsetX = e.clientX - rect.left;
229
+ dragOffsetY = e.clientY - rect.top;
230
+ iframe.style.pointerEvents = 'none';
231
+ e.preventDefault();
232
+ });
233
+
234
+ let resizing = false;
235
+ let resizeStartX = 0,
236
+ resizeStartY = 0;
237
+ let resizeStartW = 0,
238
+ resizeStartH = 0;
239
+ const MIN_W = 280,
240
+ MIN_H = 320;
241
+
242
+ resizeHandle.addEventListener('mousedown', (e: MouseEvent) => {
243
+ resizing = true;
244
+ resizeStartX = e.clientX;
245
+ resizeStartY = e.clientY;
246
+ resizeStartW = container.offsetWidth;
247
+ resizeStartH = container.offsetHeight;
248
+ iframe.style.pointerEvents = 'none';
249
+ e.preventDefault();
250
+ e.stopPropagation();
251
+ });
252
+
253
+ const onMouseMove = (e: MouseEvent) => {
254
+ if (dragging) {
255
+ const newLeft = Math.max(0, Math.min(window.innerWidth - container.offsetWidth, e.clientX - dragOffsetX));
256
+ const newTop = Math.max(0, Math.min(window.innerHeight - container.offsetHeight, e.clientY - dragOffsetY));
257
+ container.style.left = `${newLeft}px`;
258
+ container.style.top = `${newTop}px`;
259
+ } else if (resizing) {
260
+ const newW = Math.max(MIN_W, resizeStartW + (e.clientX - resizeStartX));
261
+ const newH = Math.max(MIN_H, resizeStartH + (e.clientY - resizeStartY));
262
+ container.style.width = `${newW}px`;
263
+ container.style.height = `${newH}px`;
264
+ }
265
+ };
266
+
267
+ const onMouseUp = () => {
268
+ if (dragging) {
269
+ dragHandle.style.cursor = 'grab';
270
+ }
271
+ dragging = false;
272
+ resizing = false;
273
+ iframe.style.pointerEvents = '';
274
+ };
275
+
276
+ document.addEventListener('mousemove', onMouseMove);
277
+ document.addEventListener('mouseup', onMouseUp);
278
+ this._dragCleanup = () => {
279
+ document.removeEventListener('mousemove', onMouseMove);
280
+ document.removeEventListener('mouseup', onMouseUp);
281
+ };
282
+ }
283
+
284
+ private cleanup(): void {
285
+ this._dragCleanup?.();
286
+ this._dragCleanup = null;
287
+
288
+ if (this._appOwnsContainer) {
289
+ if (this.iframe && this.iframe.parentNode) {
290
+ this.iframe.parentNode.removeChild(this.iframe);
291
+ }
292
+ } else if (this._container && this._container.parentNode) {
293
+ this._container.parentNode.removeChild(this._container);
294
+ }
295
+
296
+ this._container = null;
297
+ this.iframe = null;
298
+ }
299
+ }
300
+
301
+ /** @internal */
302
+ function waitForMessage(
303
+ predicate: (msg: Record<string, unknown>) => boolean,
304
+ timeoutMs: number,
305
+ expectedOrigin: string,
306
+ ): Promise<Record<string, unknown>> {
307
+ return new Promise((resolve, reject) => {
308
+ const timer = setTimeout(() => {
309
+ window.removeEventListener('message', handler);
310
+ reject(new Error(`Iframe wallet: timed out waiting for message (${timeoutMs}ms)`));
311
+ }, timeoutMs);
312
+
313
+ /** Handles incoming postMessage events, filtering by origin and predicate. */
314
+ function handler(event: MessageEvent) {
315
+ if (event.origin !== expectedOrigin) {
316
+ return;
317
+ }
318
+ const msg = event.data;
319
+ if (!msg || typeof msg !== 'object') {
320
+ return;
321
+ }
322
+ if (predicate(msg as Record<string, unknown>)) {
323
+ clearTimeout(timer);
324
+ window.removeEventListener('message', handler);
325
+ resolve(msg as Record<string, unknown>);
326
+ }
327
+ }
328
+
329
+ window.addEventListener('message', handler);
330
+ });
331
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * IframeWallet — Wallet proxy that communicates with a web wallet loaded in an iframe.
3
+ *
4
+ * This mirrors {@link ExtensionWallet} from `@aztec/wallet-sdk/extension/provider` but uses
5
+ * `window.postMessage` / `window.addEventListener('message')` instead of MessagePort.
6
+ *
7
+ * The wire protocol (encrypted {@link WalletMessage} / {@link WalletResponse}) is identical.
8
+ */
9
+ import type { ChainInfo } from '@aztec/aztec.js/account';
10
+ import { type Wallet, WalletSchema } from '@aztec/aztec.js/wallet';
11
+ import { jsonStringify } from '@aztec/foundation/json-rpc';
12
+ import { type PromiseWithResolvers, promiseWithResolvers } from '@aztec/foundation/promise';
13
+ import { getSchemaReturnType, schemaHasMethod } from '@aztec/foundation/schemas';
14
+ import type { FunctionsOf } from '@aztec/foundation/types';
15
+
16
+ import { type EncryptedPayload, decrypt, encrypt } from '../../crypto.js';
17
+ import {
18
+ DEFAULT_HEARTBEAT_DEAD_AFTER_MS,
19
+ DEFAULT_HEARTBEAT_INTERVAL_MS,
20
+ type DisconnectCallback,
21
+ type HeartbeatOptions,
22
+ NOOP_LOGGER,
23
+ type WalletMessage,
24
+ WalletMessageType,
25
+ type WalletResponse,
26
+ type WalletSdkLogger,
27
+ } from '../../types.js';
28
+
29
+ /**
30
+ * Internal type representing a wallet method call before encryption.
31
+ * @internal
32
+ */
33
+ type WalletMethodCall = {
34
+ /** Wallet method name to invoke. */
35
+ type: keyof FunctionsOf<Wallet>;
36
+ /** Arguments to pass to the wallet method. */
37
+ args: unknown[];
38
+ };
39
+
40
+ /**
41
+ * A wallet implementation that communicates with a web wallet loaded in an iframe
42
+ * using encrypted postMessage.
43
+ *
44
+ * Uses the same Proxy pattern as {@link ExtensionWallet}: intercepts property access,
45
+ * checks if the property is a Wallet method via {@link WalletSchema}, and routes the
46
+ * call through an encrypted postMessage channel.
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * const wallet = IframeWallet.create(walletId, sessionId, iframeWindow, walletOrigin, sharedKey, chainInfo, appId);
51
+ * const accounts = await wallet.asWallet().getAccounts();
52
+ * ```
53
+ */
54
+ export class IframeWallet {
55
+ private inFlight = new Map<string, PromiseWithResolvers<unknown>>();
56
+ private disconnected = false;
57
+ private disconnectCallbacks: DisconnectCallback[] = [];
58
+ private messageListener: ((e: MessageEvent) => void) | null = null;
59
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
60
+ private lastInboundAt = 0;
61
+ private log: WalletSdkLogger;
62
+ private heartbeatIntervalMs: number;
63
+ private heartbeatDeadAfterMs: number;
64
+
65
+ private constructor(
66
+ private chainInfo: ChainInfo,
67
+ private appId: string,
68
+ private walletId: string,
69
+ private sessionId: string,
70
+ private iframeWindow: Window,
71
+ private walletOrigin: string,
72
+ private sharedKey: CryptoKey,
73
+ logger?: WalletSdkLogger,
74
+ heartbeatOptions?: HeartbeatOptions,
75
+ ) {
76
+ this.log = logger ?? NOOP_LOGGER;
77
+ this.heartbeatIntervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
78
+ this.heartbeatDeadAfterMs = heartbeatOptions?.deadAfterMs ?? DEFAULT_HEARTBEAT_DEAD_AFTER_MS;
79
+ }
80
+
81
+ /**
82
+ * Creates a proxied IframeWallet that implements the {@link Wallet} interface.
83
+ *
84
+ * All Wallet method calls are intercepted by a Proxy, encrypted with the shared
85
+ * AES-256-GCM key, sent via postMessage, and the response is decrypted and
86
+ * validated against {@link WalletSchema}.
87
+ *
88
+ * @param walletId - Unique identifier of the remote wallet
89
+ * @param sessionId - Session identifier from the key exchange
90
+ * @param iframeWindow - The iframe's contentWindow to post messages to
91
+ * @param walletOrigin - Origin of the wallet iframe (for postMessage targeting)
92
+ * @param sharedKey - AES-256-GCM key derived from ECDH key exchange
93
+ * @param chainInfo - Network information (chainId and version)
94
+ * @param appId - Application identifier for the requesting dApp
95
+ * @param logger - Optional logger; defaults to a no-op logger to keep extension/page bundles small
96
+ * @param heartbeatOptions - Optional override for heartbeat tuning (mostly useful for tests)
97
+ * @returns A proxied IframeWallet — call `.asWallet()` to get the typed `Wallet`
98
+ */
99
+ static create(
100
+ walletId: string,
101
+ sessionId: string,
102
+ iframeWindow: Window,
103
+ walletOrigin: string,
104
+ sharedKey: CryptoKey,
105
+ chainInfo: ChainInfo,
106
+ appId: string,
107
+ logger?: WalletSdkLogger,
108
+ heartbeatOptions?: HeartbeatOptions,
109
+ ): IframeWallet {
110
+ const wallet = new IframeWallet(
111
+ chainInfo,
112
+ appId,
113
+ walletId,
114
+ sessionId,
115
+ iframeWindow,
116
+ walletOrigin,
117
+ sharedKey,
118
+ logger,
119
+ heartbeatOptions,
120
+ );
121
+
122
+ wallet.messageListener = (event: MessageEvent) => {
123
+ if (event.origin !== walletOrigin) {
124
+ return;
125
+ }
126
+ const msg = event.data;
127
+ if (!msg || typeof msg !== 'object') {
128
+ return;
129
+ }
130
+
131
+ if (msg.sessionId !== sessionId) {
132
+ return;
133
+ }
134
+
135
+ // Any inbound traffic on our session counts as proof of liveness.
136
+ wallet.lastInboundAt = Date.now();
137
+
138
+ if (msg.type === WalletMessageType.SECURE_RESPONSE) {
139
+ void wallet.handleEncryptedResponse(msg.encrypted as EncryptedPayload);
140
+ } else if (msg.type === WalletMessageType.SESSION_DISCONNECTED) {
141
+ wallet.handleDisconnect();
142
+ }
143
+ };
144
+ window.addEventListener('message', wallet.messageListener);
145
+
146
+ return new Proxy(wallet, {
147
+ get: (target, prop, receiver) => {
148
+ if (prop === 'asWallet') {
149
+ return () => receiver as unknown as Wallet;
150
+ } else if (schemaHasMethod(WalletSchema, prop.toString())) {
151
+ return async (...args: unknown[]) => {
152
+ const result = await target.postMessage({
153
+ type: prop.toString() as keyof FunctionsOf<Wallet>,
154
+ args,
155
+ });
156
+ return getSchemaReturnType(WalletSchema[prop.toString() as keyof typeof WalletSchema]).parseAsync(result);
157
+ };
158
+ } else {
159
+ return target[prop as keyof IframeWallet];
160
+ }
161
+ },
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Returns this wallet as a typed {@link Wallet} interface.
167
+ * When accessed through the Proxy (via `create()`), returns the proxy itself.
168
+ */
169
+ asWallet(): Wallet {
170
+ return this as unknown as Wallet;
171
+ }
172
+
173
+ private async handleEncryptedResponse(encrypted: EncryptedPayload): Promise<void> {
174
+ try {
175
+ const response = await decrypt<WalletResponse>(this.sharedKey, encrypted);
176
+ const { messageId, result, error, walletId: responseWalletId } = response;
177
+
178
+ if (!messageId || responseWalletId !== this.walletId) {
179
+ return;
180
+ }
181
+
182
+ const pending = this.inFlight.get(messageId);
183
+ if (!pending) {
184
+ return;
185
+ }
186
+
187
+ if (error) {
188
+ pending.reject(new Error(jsonStringify(error)));
189
+ } else {
190
+ pending.resolve(result);
191
+ }
192
+ this.inFlight.delete(messageId);
193
+ this.maybeStopHeartbeat();
194
+ } catch (err) {
195
+ this.log.warn('Failed to decrypt wallet response', { err });
196
+ }
197
+ }
198
+
199
+ private async postMessage(call: WalletMethodCall): Promise<unknown> {
200
+ if (this.disconnected) {
201
+ throw new Error('Wallet has been disconnected');
202
+ }
203
+
204
+ const messageId = globalThis.crypto.randomUUID();
205
+ const message: WalletMessage = {
206
+ type: call.type,
207
+ args: call.args,
208
+ messageId,
209
+ chainInfo: this.chainInfo,
210
+ appId: this.appId,
211
+ walletId: this.walletId,
212
+ };
213
+
214
+ const encrypted = await encrypt(this.sharedKey, jsonStringify(message));
215
+ this.iframeWindow.postMessage(
216
+ { type: WalletMessageType.SECURE_MESSAGE, sessionId: this.sessionId, encrypted },
217
+ this.walletOrigin,
218
+ );
219
+
220
+ const { promise, resolve, reject } = promiseWithResolvers<unknown>();
221
+ this.inFlight.set(messageId, { promise, resolve, reject });
222
+ this.startHeartbeat();
223
+ return promise;
224
+ }
225
+
226
+ /**
227
+ * Start liveness probing while at least one request is in flight. PINGs are
228
+ * unencrypted control messages — older wallet handlers that don't understand
229
+ * them simply drop them, but any inbound traffic (PONG, encrypted response,
230
+ * disconnect notice) resets the idle timer, so a slow-but-alive legacy wallet
231
+ * never trips a false disconnect.
232
+ */
233
+ private startHeartbeat(): void {
234
+ if (this.heartbeatTimer !== null || this.disconnected) {
235
+ return;
236
+ }
237
+ this.lastInboundAt = Date.now();
238
+ this.heartbeatTimer = setInterval(() => this.heartbeatTick(), this.heartbeatIntervalMs);
239
+ }
240
+
241
+ private maybeStopHeartbeat(): void {
242
+ if (this.inFlight.size === 0 && this.heartbeatTimer !== null) {
243
+ clearInterval(this.heartbeatTimer);
244
+ this.heartbeatTimer = null;
245
+ }
246
+ }
247
+
248
+ private heartbeatTick(): void {
249
+ if (this.disconnected || this.inFlight.size === 0) {
250
+ this.maybeStopHeartbeat();
251
+ return;
252
+ }
253
+
254
+ const idleMs = Date.now() - this.lastInboundAt;
255
+ if (idleMs >= this.heartbeatDeadAfterMs) {
256
+ this.log.warn('Iframe wallet channel unresponsive — declaring disconnect', {
257
+ idleMs,
258
+ inFlight: this.inFlight.size,
259
+ });
260
+ this.handleDisconnect();
261
+ return;
262
+ }
263
+
264
+ try {
265
+ this.iframeWindow.postMessage({ type: WalletMessageType.PING, sessionId: this.sessionId }, this.walletOrigin);
266
+ } catch (err) {
267
+ this.log.warn('Failed to send heartbeat PING', { err });
268
+ }
269
+ }
270
+
271
+ private handleDisconnect(): void {
272
+ if (this.disconnected) {
273
+ return;
274
+ }
275
+ this.disconnected = true;
276
+
277
+ if (this.heartbeatTimer !== null) {
278
+ clearInterval(this.heartbeatTimer);
279
+ this.heartbeatTimer = null;
280
+ }
281
+
282
+ if (this.messageListener) {
283
+ window.removeEventListener('message', this.messageListener);
284
+ this.messageListener = null;
285
+ }
286
+
287
+ const error = new Error('Wallet disconnected');
288
+ for (const { reject } of this.inFlight.values()) {
289
+ reject(error);
290
+ }
291
+ this.inFlight.clear();
292
+
293
+ for (const cb of this.disconnectCallbacks) {
294
+ try {
295
+ cb();
296
+ } catch {
297
+ // Ignore errors in disconnect callbacks
298
+ }
299
+ }
300
+ }
301
+
302
+ onDisconnect(callback: DisconnectCallback): () => void {
303
+ this.disconnectCallbacks.push(callback);
304
+ return () => {
305
+ const i = this.disconnectCallbacks.indexOf(callback);
306
+ if (i !== -1) {
307
+ this.disconnectCallbacks.splice(i, 1);
308
+ }
309
+ };
310
+ }
311
+
312
+ isDisconnected(): boolean {
313
+ return this.disconnected;
314
+ }
315
+
316
+ disconnect(): void {
317
+ if (this.disconnected) {
318
+ return;
319
+ }
320
+ this.iframeWindow.postMessage({ type: WalletMessageType.DISCONNECT, sessionId: this.sessionId }, this.walletOrigin);
321
+ this.handleDisconnect();
322
+ }
323
+ }
@@ -0,0 +1,3 @@
1
+ export { IframeWallet } from './iframe_wallet.js';
2
+ export { IframeWalletProvider, type IframeConnectionOptions } from './iframe_provider.js';
3
+ export { discoverWebWallets } from './iframe_discovery.js';
@@ -96,6 +96,7 @@ export interface WalletProvider {
96
96
  * matches their wallet before calling `confirm()`.
97
97
  *
98
98
  * @param appId - Application identifier for the requesting dapp
99
+ * @param options - Optional provider-specific options (e.g. container element for iframe wallets)
99
100
  * @returns A pending connection with verification hash and confirm/cancel methods
100
101
  *
101
102
  * @example
@@ -110,7 +111,7 @@ export interface WalletProvider {
110
111
  * const wallet = await pending.confirm();
111
112
  * ```
112
113
  */
113
- establishSecureChannel(appId: string): Promise<PendingConnection>;
114
+ establishSecureChannel(appId: string, options?: Record<string, unknown>): Promise<PendingConnection>;
114
115
  /**
115
116
  * Disconnects the current wallet and cleans up resources.
116
117
  * After calling this, the wallet returned from confirm() should no longer be used.