@aztec/wallet-sdk 0.0.1-commit.fff30aa → 0.0.1-dev

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 (33) hide show
  1. package/dest/base-wallet/base_wallet.d.ts +10 -2
  2. package/dest/base-wallet/base_wallet.d.ts.map +1 -1
  3. package/dest/base-wallet/base_wallet.js +19 -4
  4. package/dest/extension/handlers/background_connection_handler.d.ts +12 -2
  5. package/dest/extension/handlers/background_connection_handler.d.ts.map +1 -1
  6. package/dest/extension/handlers/background_connection_handler.js +44 -8
  7. package/dest/extension/handlers/content_script_connection_handler.d.ts +2 -1
  8. package/dest/extension/handlers/content_script_connection_handler.d.ts.map +1 -1
  9. package/dest/extension/handlers/content_script_connection_handler.js +19 -0
  10. package/dest/extension/handlers/internal_message_types.d.ts +3 -1
  11. package/dest/extension/handlers/internal_message_types.d.ts.map +1 -1
  12. package/dest/extension/handlers/internal_message_types.js +3 -1
  13. package/dest/extension/provider/extension_wallet.d.ts +26 -3
  14. package/dest/extension/provider/extension_wallet.d.ts.map +1 -1
  15. package/dest/extension/provider/extension_wallet.js +78 -7
  16. package/dest/iframe/handlers/iframe_connection_handler.d.ts +6 -2
  17. package/dest/iframe/handlers/iframe_connection_handler.d.ts.map +1 -1
  18. package/dest/iframe/handlers/iframe_connection_handler.js +16 -4
  19. package/dest/iframe/provider/iframe_wallet.d.ts +20 -3
  20. package/dest/iframe/provider/iframe_wallet.d.ts.map +1 -1
  21. package/dest/iframe/provider/iframe_wallet.js +77 -8
  22. package/dest/types.d.ts +52 -2
  23. package/dest/types.d.ts.map +1 -1
  24. package/dest/types.js +25 -0
  25. package/package.json +8 -8
  26. package/src/base-wallet/base_wallet.ts +19 -2
  27. package/src/extension/handlers/background_connection_handler.ts +42 -9
  28. package/src/extension/handlers/content_script_connection_handler.ts +18 -0
  29. package/src/extension/handlers/internal_message_types.ts +2 -0
  30. package/src/extension/provider/extension_wallet.ts +92 -6
  31. package/src/iframe/handlers/iframe_connection_handler.ts +19 -5
  32. package/src/iframe/provider/iframe_wallet.ts +101 -7
  33. package/src/types.ts +59 -0
@@ -84,7 +84,7 @@ export type FeeOptions = {
84
84
  /** Options for `simulateViaEntrypoint`. */
85
85
  export type SimulateViaEntrypointOptions = Pick<
86
86
  SimulateOptions,
87
- 'from' | 'additionalScopes' | 'skipTxValidation' | 'skipFeeEnforcement'
87
+ 'from' | 'additionalScopes' | 'skipTxValidation' | 'skipFeeEnforcement' | 'sendMessagesAs'
88
88
  > & {
89
89
  /** Fee options for the entrypoint */
90
90
  feeOptions: FeeOptions;
@@ -125,6 +125,17 @@ export abstract class BaseWallet implements Wallet {
125
125
  return [...scopeSet].map(AztecAddress.fromString);
126
126
  }
127
127
 
128
+ /**
129
+ * Picks the sender address PXE should tag private messages with. Returns `undefined` when there is no signing
130
+ * account (`from === NO_FROM`) and no explicit override; in that case any private log emitted by the tx will fail
131
+ * the contract-side `Sender for tags is not set` assertion unless `set_sender_for_tags` is called first.
132
+ * @param from - Tx sender, or `NO_FROM`.
133
+ * @param sendMessagesAs - Explicit override.
134
+ */
135
+ protected senderForTagsFrom(from: AztecAddress | NoFrom, sendMessagesAs?: AztecAddress): AztecAddress | undefined {
136
+ return sendMessagesAs ?? (from === NO_FROM ? undefined : from);
137
+ }
138
+
128
139
  protected abstract getAccountFromAddress(address: AztecAddress): Promise<Account>;
129
140
 
130
141
  abstract getAccounts(): Promise<Aliased<AztecAddress>[]>;
@@ -325,6 +336,7 @@ export abstract class BaseWallet implements Wallet {
325
336
  skipTxValidation: opts.skipTxValidation,
326
337
  skipFeeEnforcement: opts.skipFeeEnforcement,
327
338
  scopes: this.scopesFrom(opts.from, opts.additionalScopes),
339
+ senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs),
328
340
  });
329
341
  const appCallOffset = await this.computeAppCallOffset(opts.from, opts.feeOptions);
330
342
  return TxSimulationResultWithAppOffset.fromResultAndOffset(result, appCallOffset);
@@ -396,6 +408,7 @@ export abstract class BaseWallet implements Wallet {
396
408
  additionalScopes: opts.additionalScopes,
397
409
  skipTxValidation: opts.skipTxValidation,
398
410
  skipFeeEnforcement: opts.skipFeeEnforcement ?? true,
411
+ sendMessagesAs: opts.sendMessagesAs,
399
412
  })
400
413
  : Promise.resolve(null),
401
414
  ]);
@@ -414,6 +427,7 @@ export abstract class BaseWallet implements Wallet {
414
427
  profileMode: opts.profileMode,
415
428
  skipProofGeneration: opts.skipProofGeneration ?? true,
416
429
  scopes: this.scopesFrom(opts.from, opts.additionalScopes),
430
+ senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs),
417
431
  });
418
432
  }
419
433
 
@@ -427,7 +441,10 @@ export abstract class BaseWallet implements Wallet {
427
441
  gasSettings: opts.fee?.gasSettings,
428
442
  });
429
443
  const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions);
430
- const provenTx = await this.pxe.proveTx(txRequest, this.scopesFrom(opts.from, opts.additionalScopes));
444
+ const provenTx = await this.pxe.proveTx(txRequest, {
445
+ scopes: this.scopesFrom(opts.from, opts.additionalScopes),
446
+ senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs),
447
+ });
431
448
  const offchainOutput = extractOffchainOutput(
432
449
  provenTx.getOffchainEffects(),
433
450
  provenTx.publicInputs.constants.anchorBlockHeader.globalVariables.timestamp,
@@ -17,6 +17,7 @@ import {
17
17
  type WalletMessage,
18
18
  WalletMessageType,
19
19
  type WalletResponse,
20
+ type WalletSdkLogger,
20
21
  } from '../../types.js';
21
22
  import {
22
23
  type BackgroundMessage,
@@ -131,6 +132,8 @@ export interface BackgroundConnectionConfig {
131
132
  walletVersion: string;
132
133
  /** Optional wallet icon URL. */
133
134
  walletIcon?: string;
135
+ /** Logger used for diagnostics. */
136
+ logger: WalletSdkLogger;
134
137
  }
135
138
 
136
139
  /**
@@ -149,6 +152,7 @@ export interface BackgroundConnectionConfig {
149
152
  * walletId: 'my-wallet',
150
153
  * walletName: 'My Wallet',
151
154
  * walletVersion: '1.0.0',
155
+ * logger: console,
152
156
  * },
153
157
  * {
154
158
  * sendToTab: (tabId, message) => browser.tabs.sendMessage(tabId, message),
@@ -167,12 +171,15 @@ export interface BackgroundConnectionConfig {
167
171
  export class BackgroundConnectionHandler {
168
172
  private pendingDiscoveries = new Map<string, PendingDiscovery>();
169
173
  private activeSessions = new Map<string, ActiveSession>();
174
+ private log: WalletSdkLogger;
170
175
 
171
176
  constructor(
172
177
  private config: BackgroundConnectionConfig,
173
178
  private transport: BackgroundTransport,
174
179
  private callbacks: BackgroundConnectionCallbacks = {},
175
- ) {}
180
+ ) {
181
+ this.log = config.logger;
182
+ }
176
183
 
177
184
  initialize(): void {
178
185
  this.transport.addContentListener(this.handleMessage);
@@ -198,8 +205,8 @@ export class BackgroundConnectionHandler {
198
205
  break;
199
206
  case InternalMessageType.KEY_EXCHANGE_REQUEST:
200
207
  if (sessionId) {
201
- this.handleKeyExchangeRequest(sessionId, content as KeyExchangeRequest).catch(() => {
202
- // Key exchange failed - session won't be established
208
+ this.handleKeyExchangeRequest(sessionId, content as KeyExchangeRequest).catch(err => {
209
+ this.log.warn('Key exchange failed session will not be established', { sessionId, err });
203
210
  });
204
211
  }
205
212
  break;
@@ -213,9 +220,31 @@ export class BackgroundConnectionHandler {
213
220
  void this.handleEncryptedMessage(sessionId, content as EncryptedPayload);
214
221
  }
215
222
  break;
223
+ case InternalMessageType.PING:
224
+ if (sessionId) {
225
+ this.handlePing(sessionId);
226
+ }
227
+ break;
216
228
  }
217
229
  };
218
230
 
231
+ /**
232
+ * Reply to a dApp PING with a PONG. Used as a liveness probe so the dApp can
233
+ * tell the difference between a slow request and a dead extension.
234
+ * @param sessionId - The session that sent the PING.
235
+ */
236
+ private handlePing(sessionId: string): void {
237
+ const session = this.activeSessions.get(sessionId);
238
+ if (!session) {
239
+ return;
240
+ }
241
+ this.transport.sendToTab(session.tabId, {
242
+ origin: MessageOrigin.BACKGROUND,
243
+ type: InternalMessageType.PONG,
244
+ sessionId,
245
+ });
246
+ }
247
+
219
248
  getWalletInfo(): WalletInfo {
220
249
  return {
221
250
  id: this.config.walletId,
@@ -315,8 +344,8 @@ export class BackgroundConnectionHandler {
315
344
  });
316
345
 
317
346
  this.callbacks.onSessionEstablished?.(session);
318
- } catch {
319
- // Key exchange failed silently - session won't be established
347
+ } catch (err) {
348
+ this.log.warn('Key exchange failed session will not be established', { sessionId, err });
320
349
  }
321
350
  }
322
351
 
@@ -329,8 +358,8 @@ export class BackgroundConnectionHandler {
329
358
  try {
330
359
  const message = await decrypt<WalletMessage>(session.sharedKey, encrypted);
331
360
  this.callbacks.onWalletMessage?.(session, message);
332
- } catch {
333
- // Decryption failed - ignore malformed message
361
+ } catch (err) {
362
+ this.log.warn('Failed to decrypt incoming wallet message', { sessionId, err });
334
363
  }
335
364
  }
336
365
 
@@ -348,8 +377,12 @@ export class BackgroundConnectionHandler {
348
377
  sessionId,
349
378
  content: encrypted,
350
379
  });
351
- } catch {
352
- // Encryption failed - response won't be sent
380
+ } catch (err) {
381
+ this.log.error('Failed to encrypt wallet response response will not be sent', {
382
+ sessionId,
383
+ messageId: response.messageId,
384
+ err,
385
+ });
353
386
  }
354
387
  }
355
388
 
@@ -139,9 +139,20 @@ export class ContentScriptConnectionHandler {
139
139
  case InternalMessageType.SESSION_DISCONNECTED:
140
140
  this.handleSessionDisconnected(sessionId);
141
141
  break;
142
+ case InternalMessageType.PONG:
143
+ this.handlePong(sessionId);
144
+ break;
142
145
  }
143
146
  };
144
147
 
148
+ private handlePong(sessionId: string): void {
149
+ const connection = this.ports.get(sessionId);
150
+ if (!connection) {
151
+ return;
152
+ }
153
+ connection.port.postMessage({ type: WalletMessageType.PONG });
154
+ }
155
+
145
156
  private handleDiscoveryRequest(request: DiscoveryRequest): void {
146
157
  this.transport.sendToBackground({
147
158
  origin: MessageOrigin.CONTENT_SCRIPT,
@@ -178,6 +189,13 @@ export class ContentScriptConnectionHandler {
178
189
  content: data,
179
190
  });
180
191
  break;
192
+ case WalletMessageType.PING:
193
+ this.transport.sendToBackground({
194
+ origin: MessageOrigin.CONTENT_SCRIPT,
195
+ type: InternalMessageType.PING,
196
+ sessionId,
197
+ });
198
+ break;
181
199
  default:
182
200
  this.transport.sendToBackground({
183
201
  origin: MessageOrigin.CONTENT_SCRIPT,
@@ -9,11 +9,13 @@ export const InternalMessageType = {
9
9
  KEY_EXCHANGE_REQUEST: 'key-exchange-request',
10
10
  SECURE_MESSAGE: 'secure-message',
11
11
  DISCONNECT_REQUEST: 'disconnect-request',
12
+ PING: 'ping',
12
13
  // Background → Content script
13
14
  DISCOVERY_APPROVED: 'discovery-approved',
14
15
  KEY_EXCHANGE_RESPONSE: 'key-exchange-response',
15
16
  SECURE_RESPONSE: 'secure-response',
16
17
  SESSION_DISCONNECTED: 'session-disconnected',
18
+ PONG: 'pong',
17
19
  } as const;
18
20
 
19
21
  /**
@@ -6,7 +6,17 @@ import { schemaHasMethod } from '@aztec/foundation/schemas';
6
6
  import type { FunctionsOf } from '@aztec/foundation/types';
7
7
 
8
8
  import { type EncryptedPayload, decrypt, encrypt } from '../../crypto.js';
9
- import { type DisconnectCallback, type WalletMessage, WalletMessageType, type WalletResponse } from '../../types.js';
9
+ import {
10
+ DEFAULT_HEARTBEAT_DEAD_AFTER_MS,
11
+ DEFAULT_HEARTBEAT_INTERVAL_MS,
12
+ type DisconnectCallback,
13
+ type HeartbeatOptions,
14
+ NOOP_LOGGER,
15
+ type WalletMessage,
16
+ WalletMessageType,
17
+ type WalletResponse,
18
+ type WalletSdkLogger,
19
+ } from '../../types.js';
10
20
 
11
21
  /**
12
22
  * Internal type representing a wallet method call before encryption.
@@ -55,6 +65,11 @@ export class ExtensionWallet {
55
65
  private inFlight = new Map<string, PromiseWithResolvers<unknown>>();
56
66
  private disconnected = false;
57
67
  private disconnectCallbacks: DisconnectCallback[] = [];
68
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
69
+ private lastInboundAt = 0;
70
+ private log: WalletSdkLogger;
71
+ private heartbeatIntervalMs: number;
72
+ private heartbeatDeadAfterMs: number;
58
73
 
59
74
  /**
60
75
  * Private constructor - use {@link ExtensionWallet.create} to instantiate.
@@ -63,6 +78,8 @@ export class ExtensionWallet {
63
78
  * @param extensionId - The unique identifier of the target wallet extension
64
79
  * @param port - The MessagePort for private communication with the wallet
65
80
  * @param sharedKey - The derived AES-256-GCM shared key for encryption
81
+ * @param logger - Optional logger; defaults to a no-op logger
82
+ * @param heartbeatOptions - Optional heartbeat tuning (mostly useful for tests)
66
83
  */
67
84
  private constructor(
68
85
  private chainInfo: ChainInfo,
@@ -70,7 +87,13 @@ export class ExtensionWallet {
70
87
  private extensionId: string,
71
88
  private port: MessagePort,
72
89
  private sharedKey: CryptoKey,
73
- ) {}
90
+ logger?: WalletSdkLogger,
91
+ heartbeatOptions?: HeartbeatOptions,
92
+ ) {
93
+ this.log = logger ?? NOOP_LOGGER;
94
+ this.heartbeatIntervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
95
+ this.heartbeatDeadAfterMs = heartbeatOptions?.deadAfterMs ?? DEFAULT_HEARTBEAT_DEAD_AFTER_MS;
96
+ }
74
97
 
75
98
  /**
76
99
  * Creates a Wallet that communicates with a browser extension
@@ -81,6 +104,8 @@ export class ExtensionWallet {
81
104
  * @param sharedKey - The derived AES-256-GCM shared key for encryption
82
105
  * @param chainInfo - The chain information (chainId and version) for request context
83
106
  * @param appId - Application identifier used to identify the requesting dApp to the wallet
107
+ * @param logger - Optional logger; defaults to a no-op logger to keep extension/page bundles small
108
+ * @param heartbeatOptions - Optional override for heartbeat tuning (mostly useful for tests)
84
109
  * @returns A Wallet interface where all method calls are encrypted
85
110
  *
86
111
  * @example
@@ -104,13 +129,17 @@ export class ExtensionWallet {
104
129
  sharedKey: CryptoKey,
105
130
  chainInfo: ChainInfo,
106
131
  appId: string,
132
+ logger?: WalletSdkLogger,
133
+ heartbeatOptions?: HeartbeatOptions,
107
134
  ): ExtensionWallet {
108
- const wallet = new ExtensionWallet(chainInfo, appId, extensionId, port, sharedKey);
135
+ const wallet = new ExtensionWallet(chainInfo, appId, extensionId, port, sharedKey, logger, heartbeatOptions);
109
136
 
110
137
  // Set up message handler for encrypted responses and unencrypted control messages
111
138
  wallet.port.onmessage = (event: MessageEvent) => {
112
139
  const data = event.data;
113
- // Check for unencrypted disconnect notification
140
+ // Any inbound traffic counts as proof of liveness.
141
+ wallet.lastInboundAt = Date.now();
142
+
114
143
  if (data && typeof data === 'object' && 'type' in data && data.type === WalletMessageType.DISCONNECT) {
115
144
  wallet.handleDisconnect();
116
145
  return;
@@ -184,8 +213,10 @@ export class ExtensionWallet {
184
213
  resolve(result);
185
214
  }
186
215
  this.inFlight.delete(messageId);
187
- // eslint-disable-next-line no-empty
188
- } catch {}
216
+ this.maybeStopHeartbeat();
217
+ } catch (err) {
218
+ this.log.warn('Failed to decrypt wallet response', { err });
219
+ }
189
220
  }
190
221
 
191
222
  /**
@@ -223,9 +254,59 @@ export class ExtensionWallet {
223
254
 
224
255
  const { promise, resolve, reject } = promiseWithResolvers<unknown>();
225
256
  this.inFlight.set(messageId, { promise, resolve, reject });
257
+ this.startHeartbeat();
226
258
  return promise;
227
259
  }
228
260
 
261
+ /**
262
+ * Start the heartbeat probe loop while at least one request is in flight.
263
+ * Idempotent — calling while already running is a no-op.
264
+ *
265
+ * Heartbeat is opt-in via wire protocol: PINGs are unencrypted control messages
266
+ * (like DISCONNECT). Older wallets that do not understand PING simply drop it,
267
+ * which is safe — we only declare disconnect when **no** inbound traffic of any
268
+ * kind (PONG, encrypted response, DISCONNECT) arrives within the dead window.
269
+ * A wallet that is processing a slow request will reset the timer when it
270
+ * eventually responds, so this never causes false disconnects on legacy peers.
271
+ */
272
+ private startHeartbeat(): void {
273
+ if (this.heartbeatTimer !== null || this.disconnected) {
274
+ return;
275
+ }
276
+ this.lastInboundAt = Date.now();
277
+ this.heartbeatTimer = setInterval(() => this.heartbeatTick(), this.heartbeatIntervalMs);
278
+ }
279
+
280
+ private maybeStopHeartbeat(): void {
281
+ if (this.inFlight.size === 0 && this.heartbeatTimer !== null) {
282
+ clearInterval(this.heartbeatTimer);
283
+ this.heartbeatTimer = null;
284
+ }
285
+ }
286
+
287
+ private heartbeatTick(): void {
288
+ if (this.disconnected || this.inFlight.size === 0) {
289
+ this.maybeStopHeartbeat();
290
+ return;
291
+ }
292
+
293
+ const idleMs = Date.now() - this.lastInboundAt;
294
+ if (idleMs >= this.heartbeatDeadAfterMs) {
295
+ this.log.warn('Wallet channel unresponsive — declaring disconnect', {
296
+ idleMs,
297
+ inFlight: this.inFlight.size,
298
+ });
299
+ this.handleDisconnect();
300
+ return;
301
+ }
302
+
303
+ try {
304
+ this.port.postMessage({ type: WalletMessageType.PING });
305
+ } catch (err) {
306
+ this.log.warn('Failed to send heartbeat PING', { err });
307
+ }
308
+ }
309
+
229
310
  /**
230
311
  * Handles wallet disconnection.
231
312
  * Rejects all pending requests and notifies registered callbacks.
@@ -237,6 +318,11 @@ export class ExtensionWallet {
237
318
  }
238
319
  this.disconnected = true;
239
320
 
321
+ if (this.heartbeatTimer !== null) {
322
+ clearInterval(this.heartbeatTimer);
323
+ this.heartbeatTimer = null;
324
+ }
325
+
240
326
  if (this.port) {
241
327
  this.port.onmessage = null;
242
328
  this.port.close();
@@ -14,7 +14,6 @@
14
14
  * so the dApp knows it can send a discovery request.
15
15
  */
16
16
  import type { ChainInfo } from '@aztec/aztec.js/account';
17
- import { createLogger } from '@aztec/aztec.js/log';
18
17
  import type { Wallet } from '@aztec/aztec.js/wallet';
19
18
  import { WalletSchema } from '@aztec/aztec.js/wallet';
20
19
  import { jsonStringify } from '@aztec/foundation/json-rpc';
@@ -29,7 +28,7 @@ import {
29
28
  generateKeyPair,
30
29
  importPublicKey,
31
30
  } from '../../crypto.js';
32
- import { type WalletMessage, WalletMessageType, type WalletResponse } from '../../types.js';
31
+ import { type WalletMessage, WalletMessageType, type WalletResponse, type WalletSdkLogger } from '../../types.js';
33
32
 
34
33
  /**
35
34
  * A pending discovery request from a dApp (before user approval).
@@ -75,6 +74,8 @@ export interface IframeConnectionConfig {
75
74
  walletIcon?: string;
76
75
  /** Origins allowed to connect. If empty or undefined, all origins are allowed (dev mode). */
77
76
  allowedOrigins?: string[];
77
+ /** Logger used for diagnostics. */
78
+ logger: WalletSdkLogger;
78
79
  }
79
80
 
80
81
  /**
@@ -105,7 +106,7 @@ export interface IframeConnectionCallbacks {
105
106
  * @example
106
107
  * ```typescript
107
108
  * const handler = new IframeConnectionHandler(
108
- * { walletId: 'my-wallet', walletName: 'My Wallet', walletVersion: '1.0.0' },
109
+ * { walletId: 'my-wallet', walletName: 'My Wallet', walletVersion: '1.0.0', logger: console },
109
110
  * {
110
111
  * onPendingDiscovery: (session) => showApprovalUI(session),
111
112
  * getWallet: (appId, chainInfo) => createWalletForApp(appId, chainInfo),
@@ -117,12 +118,14 @@ export interface IframeConnectionCallbacks {
117
118
  export class IframeConnectionHandler {
118
119
  private pendingSessions = new Map<string, PendingSession>();
119
120
  private activeSessions = new Map<string, ActiveSession>();
120
- private log = createLogger('wallet:iframe-handler');
121
+ private log: WalletSdkLogger;
121
122
 
122
123
  constructor(
123
124
  private config: IframeConnectionConfig,
124
125
  private callbacks: IframeConnectionCallbacks,
125
- ) {}
126
+ ) {
127
+ this.log = config.logger;
128
+ }
126
129
 
127
130
  start(): void {
128
131
  window.addEventListener('message', this.handleMessage);
@@ -203,7 +206,18 @@ export class IframeConnectionHandler {
203
206
  case WalletMessageType.DISCONNECT:
204
207
  this.terminateSession(msg.sessionId);
205
208
  break;
209
+ case WalletMessageType.PING:
210
+ this.handlePing(msg.sessionId);
211
+ break;
212
+ }
213
+ }
214
+
215
+ private handlePing(sessionId: string): void {
216
+ const session = this.activeSessions.get(sessionId);
217
+ if (!session) {
218
+ return;
206
219
  }
220
+ this.postToOrigin(session.origin, { type: WalletMessageType.PONG, sessionId });
207
221
  }
208
222
 
209
223
  private handleDiscoveryRequest(msg: Record<string, unknown>, origin: string): void {
@@ -14,7 +14,17 @@ import { schemaHasMethod } from '@aztec/foundation/schemas';
14
14
  import type { FunctionsOf } from '@aztec/foundation/types';
15
15
 
16
16
  import { type EncryptedPayload, decrypt, encrypt } from '../../crypto.js';
17
- import { type DisconnectCallback, type WalletMessage, WalletMessageType, type WalletResponse } from '../../types.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';
18
28
 
19
29
  /**
20
30
  * Internal type representing a wallet method call before encryption.
@@ -46,6 +56,11 @@ export class IframeWallet {
46
56
  private disconnected = false;
47
57
  private disconnectCallbacks: DisconnectCallback[] = [];
48
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;
49
64
 
50
65
  private constructor(
51
66
  private chainInfo: ChainInfo,
@@ -55,7 +70,13 @@ export class IframeWallet {
55
70
  private iframeWindow: Window,
56
71
  private walletOrigin: string,
57
72
  private sharedKey: CryptoKey,
58
- ) {}
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
+ }
59
80
 
60
81
  /**
61
82
  * Creates a proxied IframeWallet that implements the {@link Wallet} interface.
@@ -71,6 +92,8 @@ export class IframeWallet {
71
92
  * @param sharedKey - AES-256-GCM key derived from ECDH key exchange
72
93
  * @param chainInfo - Network information (chainId and version)
73
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)
74
97
  * @returns A proxied IframeWallet — call `.asWallet()` to get the typed `Wallet`
75
98
  */
76
99
  static create(
@@ -81,8 +104,20 @@ export class IframeWallet {
81
104
  sharedKey: CryptoKey,
82
105
  chainInfo: ChainInfo,
83
106
  appId: string,
107
+ logger?: WalletSdkLogger,
108
+ heartbeatOptions?: HeartbeatOptions,
84
109
  ): IframeWallet {
85
- const wallet = new IframeWallet(chainInfo, appId, walletId, sessionId, iframeWindow, walletOrigin, sharedKey);
110
+ const wallet = new IframeWallet(
111
+ chainInfo,
112
+ appId,
113
+ walletId,
114
+ sessionId,
115
+ iframeWindow,
116
+ walletOrigin,
117
+ sharedKey,
118
+ logger,
119
+ heartbeatOptions,
120
+ );
86
121
 
87
122
  wallet.messageListener = (event: MessageEvent) => {
88
123
  if (event.origin !== walletOrigin) {
@@ -93,9 +128,16 @@ export class IframeWallet {
93
128
  return;
94
129
  }
95
130
 
96
- if (msg.type === WalletMessageType.SECURE_RESPONSE && msg.sessionId === sessionId) {
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) {
97
139
  void wallet.handleEncryptedResponse(msg.encrypted as EncryptedPayload);
98
- } else if (msg.type === WalletMessageType.SESSION_DISCONNECTED && msg.sessionId === sessionId) {
140
+ } else if (msg.type === WalletMessageType.SESSION_DISCONNECTED) {
99
141
  wallet.handleDisconnect();
100
142
  }
101
143
  };
@@ -148,8 +190,9 @@ export class IframeWallet {
148
190
  pending.resolve(result);
149
191
  }
150
192
  this.inFlight.delete(messageId);
151
- } catch {
152
- // Decryption errors are silently ignored (message not for us or corrupted)
193
+ this.maybeStopHeartbeat();
194
+ } catch (err) {
195
+ this.log.warn('Failed to decrypt wallet response', { err });
153
196
  }
154
197
  }
155
198
 
@@ -176,15 +219,66 @@ export class IframeWallet {
176
219
 
177
220
  const { promise, resolve, reject } = promiseWithResolvers<unknown>();
178
221
  this.inFlight.set(messageId, { promise, resolve, reject });
222
+ this.startHeartbeat();
179
223
  return promise;
180
224
  }
181
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
+
182
271
  private handleDisconnect(): void {
183
272
  if (this.disconnected) {
184
273
  return;
185
274
  }
186
275
  this.disconnected = true;
187
276
 
277
+ if (this.heartbeatTimer !== null) {
278
+ clearInterval(this.heartbeatTimer);
279
+ this.heartbeatTimer = null;
280
+ }
281
+
188
282
  if (this.messageListener) {
189
283
  window.removeEventListener('message', this.messageListener);
190
284
  this.messageListener = null;