@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
package/src/crypto.ts CHANGED
@@ -497,3 +497,107 @@ export function hashToEmoji(hash: string, count: number = DEFAULT_EMOJI_GRID_SIZ
497
497
  }
498
498
  return emojis.join('');
499
499
  }
500
+
501
+ // ─── Passphrase-based encryption (PBKDF2 + AES-256-GCM) ───────────────────
502
+
503
+ /** Default PBKDF2 iteration count. High to compensate for short PINs (~1-2s on modern hardware). */
504
+ const DEFAULT_PBKDF2_ITERATIONS = 2_000_000;
505
+ const PBKDF2_SALT_BYTES = 16;
506
+ const PBKDF2_IV_BYTES = 12;
507
+
508
+ /**
509
+ * Derives an AES-256-GCM key from a passphrase using PBKDF2-SHA256.
510
+ *
511
+ * @param passphrase - The user-provided passphrase or PIN
512
+ * @param salt - Random salt bytes
513
+ * @param iterations - PBKDF2 iteration count (default: 2,000,000)
514
+ * @returns An AES-256-GCM CryptoKey
515
+ */
516
+ export async function deriveKeyFromPassphrase(
517
+ passphrase: string,
518
+ salt: Uint8Array,
519
+ iterations: number = DEFAULT_PBKDF2_ITERATIONS,
520
+ ): Promise<CryptoKey> {
521
+ const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, [
522
+ 'deriveKey',
523
+ ]);
524
+ return crypto.subtle.deriveKey(
525
+ { name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' },
526
+ keyMaterial,
527
+ { name: 'AES-GCM', length: 256 },
528
+ false,
529
+ ['encrypt', 'decrypt'],
530
+ );
531
+ }
532
+
533
+ /**
534
+ * Encrypts arbitrary bytes with a passphrase using PBKDF2 + AES-256-GCM.
535
+ *
536
+ * Output layout: `[salt (16)] [iv (12)] [ciphertext (...)]`
537
+ *
538
+ * @param plaintext - Data to encrypt
539
+ * @param passphrase - User passphrase or PIN
540
+ * @param iterations - PBKDF2 iteration count (default: 2,000,000)
541
+ * @returns A Uint8Array containing salt + iv + ciphertext
542
+ */
543
+ export async function encryptWithPassphrase(
544
+ plaintext: Uint8Array,
545
+ passphrase: string,
546
+ iterations: number = DEFAULT_PBKDF2_ITERATIONS,
547
+ ): Promise<Uint8Array> {
548
+ const salt = crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_BYTES));
549
+ const iv = crypto.getRandomValues(new Uint8Array(PBKDF2_IV_BYTES));
550
+ const key = await deriveKeyFromPassphrase(passphrase, salt, iterations);
551
+ const ciphertext = new Uint8Array(
552
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext as BufferSource),
553
+ );
554
+ const result = new Uint8Array(PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES + ciphertext.length);
555
+ result.set(salt, 0);
556
+ result.set(iv, PBKDF2_SALT_BYTES);
557
+ result.set(ciphertext, PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES);
558
+ return result;
559
+ }
560
+
561
+ /**
562
+ * Decrypts data produced by {@link encryptWithPassphrase}.
563
+ *
564
+ * @param data - The encrypted blob (salt + iv + ciphertext)
565
+ * @param passphrase - The passphrase used during encryption
566
+ * @param iterations - PBKDF2 iteration count (must match encryption)
567
+ * @returns The decrypted plaintext bytes
568
+ * @throws On wrong passphrase (AES-GCM auth tag mismatch)
569
+ */
570
+ export async function decryptWithPassphrase(
571
+ data: Uint8Array,
572
+ passphrase: string,
573
+ iterations: number = DEFAULT_PBKDF2_ITERATIONS,
574
+ ): Promise<Uint8Array> {
575
+ const salt = data.slice(0, PBKDF2_SALT_BYTES);
576
+ const iv = data.slice(PBKDF2_SALT_BYTES, PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES);
577
+ const ciphertext = data.slice(PBKDF2_SALT_BYTES + PBKDF2_IV_BYTES);
578
+ const key = await deriveKeyFromPassphrase(passphrase, salt, iterations);
579
+ return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext as BufferSource));
580
+ }
581
+
582
+ /**
583
+ * Converts a Uint8Array to a base64 string.
584
+ */
585
+ export function uint8ToBase64(bytes: Uint8Array): string {
586
+ let binary = '';
587
+ for (const b of bytes) {
588
+ binary += String.fromCharCode(b);
589
+ }
590
+ return btoa(binary);
591
+ }
592
+
593
+ /**
594
+ * Converts a base64 string to a Uint8Array.
595
+ */
596
+ export function base64ToUint8(b64: string): Uint8Array {
597
+ const binary = atob(b64);
598
+ const bytes = new Uint8Array(binary.length);
599
+ for (let i = 0; i < binary.length; i++) {
600
+ bytes[i] = binary.charCodeAt(i);
601
+ }
602
+ return bytes;
603
+ }
@@ -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
  /**
@@ -2,11 +2,21 @@ import type { ChainInfo } from '@aztec/aztec.js/account';
2
2
  import { type Wallet, WalletSchema } from '@aztec/aztec.js/wallet';
3
3
  import { jsonStringify } from '@aztec/foundation/json-rpc';
4
4
  import { type PromiseWithResolvers, promiseWithResolvers } from '@aztec/foundation/promise';
5
- import { schemaHasMethod } from '@aztec/foundation/schemas';
5
+ import { getSchemaReturnType, 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 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.
@@ -19,11 +29,6 @@ type WalletMethodCall = {
19
29
  args: unknown[];
20
30
  };
21
31
 
22
- /**
23
- * Callback type for wallet disconnect events.
24
- */
25
- export type DisconnectCallback = () => void;
26
-
27
32
  /**
28
33
  * A wallet implementation that communicates with browser extension wallets
29
34
  * using an encrypted MessageChannel.
@@ -60,6 +65,11 @@ export class ExtensionWallet {
60
65
  private inFlight = new Map<string, PromiseWithResolvers<unknown>>();
61
66
  private disconnected = false;
62
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;
63
73
 
64
74
  /**
65
75
  * Private constructor - use {@link ExtensionWallet.create} to instantiate.
@@ -68,6 +78,8 @@ export class ExtensionWallet {
68
78
  * @param extensionId - The unique identifier of the target wallet extension
69
79
  * @param port - The MessagePort for private communication with the wallet
70
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)
71
83
  */
72
84
  private constructor(
73
85
  private chainInfo: ChainInfo,
@@ -75,7 +87,13 @@ export class ExtensionWallet {
75
87
  private extensionId: string,
76
88
  private port: MessagePort,
77
89
  private sharedKey: CryptoKey,
78
- ) {}
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
+ }
79
97
 
80
98
  /**
81
99
  * Creates a Wallet that communicates with a browser extension
@@ -86,6 +104,8 @@ export class ExtensionWallet {
86
104
  * @param sharedKey - The derived AES-256-GCM shared key for encryption
87
105
  * @param chainInfo - The chain information (chainId and version) for request context
88
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)
89
109
  * @returns A Wallet interface where all method calls are encrypted
90
110
  *
91
111
  * @example
@@ -109,13 +129,17 @@ export class ExtensionWallet {
109
129
  sharedKey: CryptoKey,
110
130
  chainInfo: ChainInfo,
111
131
  appId: string,
132
+ logger?: WalletSdkLogger,
133
+ heartbeatOptions?: HeartbeatOptions,
112
134
  ): ExtensionWallet {
113
- const wallet = new ExtensionWallet(chainInfo, appId, extensionId, port, sharedKey);
135
+ const wallet = new ExtensionWallet(chainInfo, appId, extensionId, port, sharedKey, logger, heartbeatOptions);
114
136
 
115
137
  // Set up message handler for encrypted responses and unencrypted control messages
116
138
  wallet.port.onmessage = (event: MessageEvent) => {
117
139
  const data = event.data;
118
- // Check for unencrypted disconnect notification
140
+ // Any inbound traffic counts as proof of liveness.
141
+ wallet.lastInboundAt = Date.now();
142
+
119
143
  if (data && typeof data === 'object' && 'type' in data && data.type === WalletMessageType.DISCONNECT) {
120
144
  wallet.handleDisconnect();
121
145
  return;
@@ -136,7 +160,7 @@ export class ExtensionWallet {
136
160
  type: prop.toString() as keyof FunctionsOf<Wallet>,
137
161
  args,
138
162
  });
139
- return WalletSchema[prop.toString() as keyof typeof WalletSchema].returnType().parseAsync(result);
163
+ return getSchemaReturnType(WalletSchema[prop.toString() as keyof typeof WalletSchema]).parseAsync(result);
140
164
  };
141
165
  } else {
142
166
  return target[prop as keyof ExtensionWallet];
@@ -189,8 +213,10 @@ export class ExtensionWallet {
189
213
  resolve(result);
190
214
  }
191
215
  this.inFlight.delete(messageId);
192
- // eslint-disable-next-line no-empty
193
- } catch {}
216
+ this.maybeStopHeartbeat();
217
+ } catch (err) {
218
+ this.log.warn('Failed to decrypt wallet response', { err });
219
+ }
194
220
  }
195
221
 
196
222
  /**
@@ -228,9 +254,59 @@ export class ExtensionWallet {
228
254
 
229
255
  const { promise, resolve, reject } = promiseWithResolvers<unknown>();
230
256
  this.inFlight.set(messageId, { promise, resolve, reject });
257
+ this.startHeartbeat();
231
258
  return promise;
232
259
  }
233
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
+
234
310
  /**
235
311
  * Handles wallet disconnection.
236
312
  * Rejects all pending requests and notifies registered callbacks.
@@ -242,6 +318,11 @@ export class ExtensionWallet {
242
318
  }
243
319
  this.disconnected = true;
244
320
 
321
+ if (this.heartbeatTimer !== null) {
322
+ clearInterval(this.heartbeatTimer);
323
+ this.heartbeatTimer = null;
324
+ }
325
+
245
326
  if (this.port) {
246
327
  this.port.onmessage = null;
247
328
  this.port.close();
@@ -1,4 +1,4 @@
1
- export { ExtensionWallet, type DisconnectCallback } from './extension_wallet.js';
1
+ export { ExtensionWallet } from './extension_wallet.js';
2
2
  export {
3
3
  ExtensionProvider,
4
4
  type DiscoveredWallet,