@anywalletkit/extension-kit 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -33,11 +33,11 @@ pnpm add @anywalletkit/extension-kit @anywalletkit/core @anywalletkit/keyring
33
33
 
34
34
  | Area | Exports | What |
35
35
  | --- | --- | --- |
36
- | Vault | `Vault`, `chromeLocalVaultStorage`, `memoryVaultStorage`, `VAULT_SCRYPT`, `VAULT_MIN_PASSWORD_LENGTH` | every secret at rest in `chrome.storage.local`, two key layers (a KEK wraps a DEK, the DEK seals each secret); passkey (WebAuthn PRF) unlock as a second wrap of the same DEK |
36
+ | Vault | `Vault`, `chromeLocalVaultStorage`, `memoryVaultStorage`, `VAULT_SCRYPT`, `VAULT_LIMITS`, `VAULT_MIN_PASSWORD_LENGTH` | every secret at rest in `chrome.storage.local`, two key layers (a KEK wraps a DEK, the DEK seals each secret); passkey (WebAuthn PRF) unlock as a second wrap of the same DEK |
37
37
  | Session | `Session`, `chromeSessionStorage`, `chromeAlarms`, `SESSION_DEFAULT_AUTOLOCK_MS` | the DEK in `chrome.storage.session` with an expiry and an alarm; `getKey()` re-checks the expiry itself |
38
38
  | Keyring adapters | `chromeKeyringStorage`, `vaultSecretStorage` | `@anywalletkit/keyring` metadata as plain JSON, secrets through the vault, one DEK use per call |
39
39
  | Relay | `PageRequest` / `RelayRequest` shapes and guards, `stampOrigin`, `checkSenderOrigin`, `RELAY_ERRORS` | page ⇄ content script ⇄ service worker messages; the origin is the browser's (`port.sender`), never the page's |
40
- | Router | `RequestRouter`, `READ_METHODS`, `SIGNING_METHODS` | what a dApp may call: accounts only for connected origins, chain add / switch through the approval window, reads from the wallet's own RPCs, `eth_sign` never offered, everything else 4200 |
40
+ | Router | `RequestRouter`, `READ_METHODS`, `SIGNING_METHODS`, `toRelayError` | what a dApp may call: accounts only for connected origins, chain add / switch through the approval window, reads from the wallet's own RPCs, `eth_sign` never offered, everything else 4200 |
41
41
  | Permissions | `PermissionStore`, `CHAIN_ACCOUNT_KEYS` | per-origin accounts (EVM, Solana, TON, Tron, Bitcoin, NEAR) and chain |
42
42
  | Approvals | `ApprovalQueue`, `ReviewDeclined`, `QueueLimitError`, `APPROVALS_MAX_PENDING_PER_ORIGIN` | one approval window at a time, at most 3 pending requests per origin and one pending `connect`; transport notes (`setVia`) for WalletConnect peers |
43
43
  | Signing | `classifySignRequest`, `prepareTransaction`, `createSignHandler`, `messageText` | connected-address and chain checks, EIP-7702 delegations from sites refused, nonce / gas / fees from the wallet's RPC |
@@ -57,7 +57,11 @@ pnpm add @anywalletkit/extension-kit @anywalletkit/core @anywalletkit/keyring
57
57
  AES-256-GCM wraps a random 32-byte DEK; the DEK seals each secret under AAD
58
58
  `forgewallet-vault/secret/<id>` (boxes cannot be swapped). The DEK box's AAD
59
59
  binds the exact KDF parameters, so an envelope whose salt or cost was edited
60
- on disk fails to open; `unlock` refuses sub-floor costs (`weak-kdf`). v1
60
+ on disk fails to open; `unlock` refuses sub-floor costs (`weak-kdf`), and
61
+ costs or sizes above `VAULT_LIMITS` (scrypt N ≤ 2^20, r ≤ 16, p ≤ 4; PBKDF2
62
+ ≤ 5 000 000 iterations; 1 000 secrets, 32 passkeys, 4 MiB) before any KDF
63
+ runs (`unsupported-envelope`). Writes to the envelope are serialized (one
64
+ `Vault` per storage area), so parallel imports keep every secret. v1
61
65
  envelopes (PBKDF2-SHA256, ≥ 600 000 iterations) still open and are re-sealed
62
66
  as v2. Passwords shorter than 8 characters are refused by the vault itself.
63
67
  Wrong password and tampering are the same failure (`wrong-password`).
@@ -67,6 +71,11 @@ pnpm add @anywalletkit/extension-kit @anywalletkit/core @anywalletkit/keyring
67
71
  - **Unlocked state.** Only the DEK is kept, in `chrome.storage.session`
68
72
  (memory-only, trusted contexts), with an expiry enforced on every read and an
69
73
  alarm that locks; key buffers this package owns are zeroized after use.
74
+ `unlock` / `lock` / `touch` / `getKey` run one at a time, so activity racing a
75
+ lock never brings the key back.
76
+ - **Errors to pages.** `toRelayError(e, { log })` passes the wallet's own refusals
77
+ (EIP-1193 codes) and a node's JSON-RPC error (code, revert data) with URLs
78
+ removed; anything else is `-32603 Internal error` on the page and goes to `log`.
70
79
  - **Origins.** The service worker answers a relay message only when the
71
80
  content script's stamped origin equals `port.sender.origin`. Permissions are
72
81
  keyed by that origin, never by a URL a page or a WalletConnect peer claims.
package/dist/index.cjs CHANGED
@@ -63,6 +63,7 @@ __export(src_exports, {
63
63
  TRON_PROVIDERS: () => TRON_PROVIDERS,
64
64
  TonConnectError: () => TonConnectError,
65
65
  VAULT_DEFAULT_ITERATIONS: () => VAULT_DEFAULT_ITERATIONS,
66
+ VAULT_LIMITS: () => VAULT_LIMITS,
66
67
  VAULT_MIN_ITERATIONS: () => VAULT_MIN_ITERATIONS,
67
68
  VAULT_MIN_PASSWORD_LENGTH: () => VAULT_MIN_PASSWORD_LENGTH,
68
69
  VAULT_MIN_SCRYPT: () => VAULT_MIN_SCRYPT,
@@ -185,6 +186,22 @@ var VAULT_DEFAULT_ITERATIONS = 6e5;
185
186
  var VAULT_SCRYPT = { N: 1 << 17, r: 8, p: 1 };
186
187
  var VAULT_MIN_SCRYPT = { N: 1 << 14, r: 8, p: 1 };
187
188
  var VAULT_MIN_PASSWORD_LENGTH = 8;
189
+ var VAULT_LIMITS = Object.freeze({
190
+ /** scrypt cost: 2^20 × 16 = 2 GiB is the most any envelope may ask for. */
191
+ scrypt: Object.freeze({ N: 1 << 20, r: 16, p: 4 }),
192
+ /** PBKDF2-SHA256 iterations (v1 envelopes). */
193
+ iterations: 5e6,
194
+ /** Sealed secrets in one vault. */
195
+ secrets: 1e3,
196
+ /** Registered passkeys. */
197
+ passkeys: 32,
198
+ /** Plaintext bytes of one secret (`setSecret`). */
199
+ secretBytes: 16 * 1024,
200
+ /** base64 characters of one sealed box (iv or data). */
201
+ boxChars: 32 * 1024,
202
+ /** JSON characters of the whole envelope. */
203
+ envelopeChars: 4 * 1024 * 1024
204
+ });
188
205
  var SALT_BYTES = 32;
189
206
  var IV_BYTES = 12;
190
207
  var DEK_BYTES = 32;
@@ -196,6 +213,8 @@ var aadSecret = (id) => `forgewallet-vault/secret/${id}`;
196
213
  var HKDF_INFO = "forgewallet-vault/2/passkey";
197
214
  var Vault = class {
198
215
  _storage;
216
+ /** Every read-modify-write of the envelope runs here, one after another (a parallel write would drop the other's change). */
217
+ _queue = Promise.resolve();
199
218
  _kdf;
200
219
  _iterations;
201
220
  _scrypt;
@@ -208,10 +227,11 @@ var Vault = class {
208
227
  this._scrypt = options.scrypt ?? VAULT_SCRYPT;
209
228
  this._onProgress = options.onProgress;
210
229
  this._crypto = options.crypto ?? globalThis.crypto;
211
- if (!Number.isInteger(this._iterations) || this._iterations < VAULT_MIN_ITERATIONS) {
212
- throw new VaultError(`PBKDF2 iterations must be an integer \u2265 ${VAULT_MIN_ITERATIONS}`, "invalid-input");
230
+ if (!Number.isInteger(this._iterations) || this._iterations < VAULT_MIN_ITERATIONS || this._iterations > VAULT_LIMITS.iterations) {
231
+ throw new VaultError(`PBKDF2 iterations must be an integer from ${VAULT_MIN_ITERATIONS} to ${VAULT_LIMITS.iterations}`, "invalid-input");
213
232
  }
214
233
  if (!isStrongScrypt(this._scrypt)) throw new VaultError(`scrypt cost must be at least N=${VAULT_MIN_SCRYPT.N} r=${VAULT_MIN_SCRYPT.r} p=${VAULT_MIN_SCRYPT.p} (N a power of two)`, "invalid-input");
234
+ if (!isBoundedScrypt(this._scrypt)) throw new VaultError(`scrypt cost must be at most N=${VAULT_LIMITS.scrypt.N} r=${VAULT_LIMITS.scrypt.r} p=${VAULT_LIMITS.scrypt.p}`, "invalid-input");
215
235
  }
216
236
  async exists() {
217
237
  return await this._storage.get() !== null;
@@ -230,10 +250,12 @@ var Vault = class {
230
250
  */
231
251
  async create(password) {
232
252
  requirePassword(password, VAULT_MIN_PASSWORD_LENGTH);
233
- if (await this.exists()) throw new VaultError("A vault already exists; unlock it or reset explicitly.", "exists");
234
- const dek = this._random(DEK_BYTES);
235
- await this._storage.set(await this._sealEnvelope(password, dek, {}, [], Date.now()));
236
- return dek;
253
+ return this._exclusive(async () => {
254
+ if (await this.exists()) throw new VaultError("A vault already exists; unlock it or reset explicitly.", "exists");
255
+ const dek = this._random(DEK_BYTES);
256
+ await this._write(await this._sealEnvelope(password, dek, {}, [], Date.now()));
257
+ return dek;
258
+ });
237
259
  }
238
260
  /**
239
261
  * Derive the KEK from `password`, unseal and return the DEK. `wrong-password` on
@@ -242,6 +264,9 @@ var Vault = class {
242
264
  */
243
265
  async unlock(password) {
244
266
  requirePassword(password);
267
+ return this._exclusive(() => this._unlock(password));
268
+ }
269
+ async _unlock(password) {
245
270
  const envelope = await this._load();
246
271
  if (isEnvelopeV1(envelope)) {
247
272
  if (envelope.kdf.iterations < VAULT_MIN_ITERATIONS) throw new VaultError(`The vault's KDF (${envelope.kdf.iterations} iterations) is below the floor this build accepts.`, "weak-kdf");
@@ -249,7 +274,7 @@ var Vault = class {
249
274
  const dek = await this._open(kek2, envelope.dek, aadDekV1(envelope.kdf), "wrong-password");
250
275
  if (this._kdf === "scrypt") {
251
276
  const next = await this._sealEnvelope(password, dek, envelope.secrets, [], envelope.createdAt);
252
- await this._storage.set(isEnvelopeV2(next) ? { ...next, upgradedAt: Date.now() } : next);
277
+ await this._write(isEnvelopeV2(next) ? { ...next, upgradedAt: Date.now() } : next);
253
278
  }
254
279
  return dek;
255
280
  }
@@ -260,15 +285,18 @@ var Vault = class {
260
285
  /** Re-wrap the DEK under a new password (fresh salt, current KDF). Secrets and passkeys are untouched. */
261
286
  async changePassword(oldPassword, newPassword) {
262
287
  requirePassword(newPassword, VAULT_MIN_PASSWORD_LENGTH);
263
- const dek = await this.unlock(oldPassword);
264
- try {
265
- const envelope = await this._load();
266
- const passkeys = isEnvelopeV2(envelope) ? envelope.passkeys : [];
267
- const next = await this._sealEnvelope(newPassword, dek, envelope.secrets, passkeys, envelope.createdAt);
268
- await this._storage.set(isEnvelopeV2(envelope) && envelope.upgradedAt !== void 0 && isEnvelopeV2(next) ? { ...next, upgradedAt: envelope.upgradedAt } : next);
269
- } finally {
270
- (0, import_core.zeroize)(dek);
271
- }
288
+ requirePassword(oldPassword);
289
+ return this._exclusive(async () => {
290
+ const dek = await this._unlock(oldPassword);
291
+ try {
292
+ const envelope = await this._load();
293
+ const passkeys = isEnvelopeV2(envelope) ? envelope.passkeys : [];
294
+ const next = await this._sealEnvelope(newPassword, dek, envelope.secrets, passkeys, envelope.createdAt);
295
+ await this._write(isEnvelopeV2(envelope) && envelope.upgradedAt !== void 0 && isEnvelopeV2(next) ? { ...next, upgradedAt: envelope.upgradedAt } : next);
296
+ } finally {
297
+ (0, import_core.zeroize)(dek);
298
+ }
299
+ });
272
300
  }
273
301
  // ---- passkeys (v2) ----
274
302
  /** Public view of the registered passkeys (ids, labels; whether each can verify assertions). */
@@ -288,27 +316,30 @@ var Vault = class {
288
316
  * Register a passkey as a second unlock path: the DEK (from the unlocked session)
289
317
  * is sealed under HKDF-SHA256(`prf`, salt = credential id). The password stays
290
318
  * the recovery path; `prf` is zeroized here. A v1 envelope must be unlocked (and
291
- * thereby upgraded) first.
319
+ * thereby upgraded) first, and the vault must hold a secret (it proves the DEK).
292
320
  */
293
321
  async addPasskey(dek, passkey) {
294
322
  try {
295
323
  requireId(passkey.id);
296
324
  if (passkey.prf.length !== PRF_BYTES) throw new VaultError(`A PRF output is ${PRF_BYTES} bytes.`, "invalid-input");
297
- const envelope = await this._load();
298
- if (!isEnvelopeV2(envelope)) throw new VaultError("Unlock with the password once so the vault is upgraded before adding a passkey.", "unsupported-envelope");
299
- if (envelope.passkeys.some((p) => p.id === passkey.id)) throw new VaultError("This passkey is already registered.", "exists");
300
- await this._requireDek(dek, envelope);
301
- const key = await this._derivePasskeyKek(passkey.prf, passkey.id);
302
- const entry = { id: passkey.id, label: passkey.label.slice(0, 64), createdAt: Date.now(), dek: await this._seal(key, dek, aadPasskey(passkey.id)) };
303
- if (passkey.publicKey && passkey.alg !== void 0) {
304
- entry.publicKey = passkey.publicKey;
305
- entry.alg = passkey.alg;
306
- }
307
- await this._storage.set({ ...envelope, passkeys: [...envelope.passkeys, entry] });
325
+ await this._exclusive(() => this._addPasskey(dek, passkey));
308
326
  } finally {
309
327
  (0, import_core.zeroize)(passkey.prf);
310
328
  }
311
329
  }
330
+ async _addPasskey(dek, passkey) {
331
+ const envelope = await this._load();
332
+ if (!isEnvelopeV2(envelope)) throw new VaultError("Unlock with the password once so the vault is upgraded before adding a passkey.", "unsupported-envelope");
333
+ if (envelope.passkeys.some((p) => p.id === passkey.id)) throw new VaultError("This passkey is already registered.", "exists");
334
+ await this._requireDek(dek, envelope);
335
+ const key = await this._derivePasskeyKek(passkey.prf, passkey.id);
336
+ const entry = { id: passkey.id, label: passkey.label.slice(0, 64), createdAt: Date.now(), dek: await this._seal(key, dek, aadPasskey(passkey.id)) };
337
+ if (passkey.publicKey && passkey.alg !== void 0) {
338
+ entry.publicKey = passkey.publicKey;
339
+ entry.alg = passkey.alg;
340
+ }
341
+ await this._write({ ...envelope, passkeys: [...envelope.passkeys, entry] });
342
+ }
312
343
  /** Unseal the DEK with a passkey's PRF output. `wrong-password` when the box does not open, `missing` when the id is unknown. */
313
344
  async unlockWithPasskey(id, prf) {
314
345
  try {
@@ -326,9 +357,11 @@ var Vault = class {
326
357
  }
327
358
  async removePasskey(id) {
328
359
  requireId(id);
329
- const envelope = await this._load();
330
- if (!isEnvelopeV2(envelope)) return;
331
- await this._storage.set({ ...envelope, passkeys: envelope.passkeys.filter((p) => p.id !== id) });
360
+ return this._exclusive(async () => {
361
+ const envelope = await this._load();
362
+ if (!isEnvelopeV2(envelope)) return;
363
+ await this._write({ ...envelope, passkeys: envelope.passkeys.filter((p) => p.id !== id) });
364
+ });
332
365
  }
333
366
  // ---- secrets ----
334
367
  async listSecretIds() {
@@ -338,10 +371,16 @@ var Vault = class {
338
371
  /** Seal `secret` under the DEK as `sourceId` (overwrites an entry with the same id). */
339
372
  async setSecret(dek, sourceId, secret) {
340
373
  requireId(sourceId);
341
- const envelope = await this._load();
374
+ if (secret.length > VAULT_LIMITS.secretBytes) throw new VaultError(`A secret is at most ${VAULT_LIMITS.secretBytes} bytes.`, "invalid-input");
342
375
  const key = await this._importDek(dek);
343
- const box = await this._seal(key, secret, aadSecret(sourceId));
344
- await this._storage.set({ ...envelope, secrets: { ...envelope.secrets, [sourceId]: box } });
376
+ return this._exclusive(async () => {
377
+ const envelope = await this._load();
378
+ if (!(sourceId in envelope.secrets) && Object.keys(envelope.secrets).length >= VAULT_LIMITS.secrets) {
379
+ throw new VaultError(`A vault holds at most ${VAULT_LIMITS.secrets} secrets.`, "invalid-input");
380
+ }
381
+ const box = await this._seal(key, secret, aadSecret(sourceId));
382
+ await this._write({ ...envelope, secrets: { ...envelope.secrets, [sourceId]: box } });
383
+ });
345
384
  }
346
385
  /** Unseal one secret. `null` when the id is unknown; the caller zeroizes the result after use. */
347
386
  async getSecret(dek, sourceId) {
@@ -354,15 +393,29 @@ var Vault = class {
354
393
  }
355
394
  async deleteSecret(sourceId) {
356
395
  requireId(sourceId);
357
- const envelope = await this._load();
358
- if (!(sourceId in envelope.secrets)) return;
359
- const secrets = { ...envelope.secrets };
360
- delete secrets[sourceId];
361
- await this._storage.set({ ...envelope, secrets });
396
+ return this._exclusive(async () => {
397
+ const envelope = await this._load();
398
+ if (!(sourceId in envelope.secrets)) return;
399
+ const secrets = { ...envelope.secrets };
400
+ delete secrets[sourceId];
401
+ await this._write({ ...envelope, secrets });
402
+ });
362
403
  }
363
404
  /** Remove the whole vault. The explicit destructive flow only (back up → wipe → create). */
364
405
  async destroy() {
365
- await this._storage.clear();
406
+ return this._exclusive(() => this._storage.clear());
407
+ }
408
+ // ---- serialized writes ----
409
+ _exclusive(fn) {
410
+ const run = this._queue.then(fn);
411
+ this._queue = run.catch(() => void 0);
412
+ return run;
413
+ }
414
+ /** Store an envelope this build will open again (bounds checked before it is written). */
415
+ async _write(envelope) {
416
+ const problem = envelopeProblem(envelope);
417
+ if (problem) throw new VaultError(`The vault would exceed its limits (${problem}); nothing was written.`, "invalid-input");
418
+ await this._storage.set(envelope);
366
419
  }
367
420
  // ---- crypto ----
368
421
  _random(n) {
@@ -380,11 +433,14 @@ var Vault = class {
380
433
  const kek = await this._deriveScrypt(password, salt, kdf);
381
434
  return { v: 2, kdf, dek: await this._seal(kek, dek, aadDekV2(kdf)), secrets, passkeys, createdAt };
382
435
  }
383
- /** The caller's DEK must open this envelope's secrets — it is the key the password protects, not any 32 bytes. */
436
+ /**
437
+ * The caller's DEK must open this envelope's secrets — it is the key the password protects, not
438
+ * any 32 bytes. An empty vault has nothing to prove it with, so it is refused rather than trusted.
439
+ */
384
440
  async _requireDek(dek, envelope) {
385
441
  const ids = Object.keys(envelope.secrets);
386
442
  const key = await this._importDek(dek);
387
- if (ids.length === 0) return;
443
+ if (ids.length === 0) throw new VaultError("Add a secret to the vault before a passkey: an empty vault cannot prove the key is its own.", "invalid-input");
388
444
  const probe = await this._open(key, envelope.secrets[ids[0]], aadSecret(ids[0]), "locked");
389
445
  (0, import_core.zeroize)(probe);
390
446
  }
@@ -438,6 +494,8 @@ var Vault = class {
438
494
  const envelope = await this._storage.get();
439
495
  if (!envelope) throw new VaultError("No vault in this storage area.", "missing");
440
496
  if (!isEnvelopeV1(envelope) && !isEnvelopeV2(envelope)) throw new VaultError("Unsupported vault envelope.", "unsupported-envelope");
497
+ const problem = envelopeProblem(envelope);
498
+ if (problem) throw new VaultError(`The vault envelope is outside the limits this build opens (${problem}).`, "unsupported-envelope");
441
499
  return envelope;
442
500
  }
443
501
  };
@@ -456,6 +514,22 @@ function isEnvelopeV2(x) {
456
514
  function isBox(x) {
457
515
  return typeof x === "object" && x !== null && typeof x.iv === "string" && typeof x.data === "string";
458
516
  }
517
+ function isBoundedScrypt(c) {
518
+ return c.N <= VAULT_LIMITS.scrypt.N && c.r <= VAULT_LIMITS.scrypt.r && c.p <= VAULT_LIMITS.scrypt.p;
519
+ }
520
+ function envelopeProblem(e) {
521
+ if (e.v === 1 && e.kdf.iterations > VAULT_LIMITS.iterations) return `PBKDF2 iterations ${e.kdf.iterations} > ${VAULT_LIMITS.iterations}`;
522
+ if (e.v === 2 && !isBoundedScrypt(e.kdf)) return `scrypt N=${e.kdf.N} r=${e.kdf.r} p=${e.kdf.p} above N=${VAULT_LIMITS.scrypt.N} r=${VAULT_LIMITS.scrypt.r} p=${VAULT_LIMITS.scrypt.p}`;
523
+ const secrets = Object.values(e.secrets);
524
+ if (secrets.length > VAULT_LIMITS.secrets) return `${secrets.length} secrets > ${VAULT_LIMITS.secrets}`;
525
+ const passkeys = e.v === 2 ? e.passkeys : [];
526
+ if (passkeys.length > VAULT_LIMITS.passkeys) return `${passkeys.length} passkeys > ${VAULT_LIMITS.passkeys}`;
527
+ for (const box of [e.dek, ...secrets, ...passkeys.map((p) => p.dek)]) {
528
+ if (box.iv.length > VAULT_LIMITS.boxChars || box.data.length > VAULT_LIMITS.boxChars) return `a sealed box above ${VAULT_LIMITS.boxChars} characters`;
529
+ }
530
+ if (JSON.stringify(e).length > VAULT_LIMITS.envelopeChars) return `envelope above ${VAULT_LIMITS.envelopeChars} characters`;
531
+ return null;
532
+ }
459
533
  function isStrongScrypt(c) {
460
534
  return Number.isInteger(c.N) && c.N >= VAULT_MIN_SCRYPT.N && (c.N & c.N - 1) === 0 && Number.isInteger(c.r) && c.r >= VAULT_MIN_SCRYPT.r && Number.isInteger(c.p) && c.p >= VAULT_MIN_SCRYPT.p;
461
535
  }
@@ -500,6 +574,13 @@ var Session = class {
500
574
  _alarm;
501
575
  _now;
502
576
  _onLock = /* @__PURE__ */ new Set();
577
+ /**
578
+ * Every operation on the stored record runs here, one after another: a `touch` that read the
579
+ * record before a `lock` removed it would otherwise write it back and undo the lock.
580
+ */
581
+ _queue = Promise.resolve();
582
+ /** Bumped by every lock; a `touch` writes only when no lock happened since it read the record. */
583
+ _epoch = 0;
503
584
  constructor(options) {
504
585
  this._storage = options.storage;
505
586
  this._alarms = options.alarms;
@@ -515,20 +596,19 @@ var Session = class {
515
596
  /** Store the DEK and arm the auto-lock. The caller's copy is zeroized. */
516
597
  async unlock(dek) {
517
598
  if (dek.length !== 32) throw new VaultError("Vault key must be 32 bytes.", "invalid-input");
518
- const expiresAt = this._expiry();
519
- await this._storage.set(this._key, { key: toBase64(dek), expiresAt });
520
- (0, import_core2.zeroize)(dek);
521
- await this._arm(expiresAt);
599
+ return this._serial(async () => {
600
+ const expiresAt = this._expiry();
601
+ await this._storage.set(this._key, { key: toBase64(dek), expiresAt });
602
+ (0, import_core2.zeroize)(dek);
603
+ await this._arm(expiresAt);
604
+ });
522
605
  }
523
606
  /** Forget the DEK and cancel the alarm. Safe to call when already locked. */
524
607
  async lock() {
525
- const was = await this._storage.get(this._key);
526
- await this._storage.remove(this._key);
527
- await this._alarms.clear(this._alarm);
528
- if (was !== void 0 && was !== null) for (const cb of this._onLock) cb();
608
+ return this._serial(() => this._lockNow());
529
609
  }
530
610
  async isUnlocked() {
531
- return await this._read() !== null;
611
+ return this._serial(async () => await this._read() !== null);
532
612
  }
533
613
  /**
534
614
  * The DEK for one operation (zeroize it after). `locked` when there is none or the
@@ -536,17 +616,22 @@ var Session = class {
536
616
  * a missed alarm cannot keep the key alive.
537
617
  */
538
618
  async getKey() {
539
- const record = await this._read();
540
- if (!record) throw new VaultError("Wallet is locked.", "locked");
541
- return fromBase64(record.key);
619
+ return this._serial(async () => {
620
+ const record = await this._read();
621
+ if (!record) throw new VaultError("Wallet is locked.", "locked");
622
+ return fromBase64(record.key);
623
+ });
542
624
  }
543
625
  /** User activity: push the auto-lock out again. No-op when locked. */
544
626
  async touch() {
545
- const record = await this._read();
546
- if (!record) return;
547
- const expiresAt = this._expiry();
548
- await this._storage.set(this._key, { ...record, expiresAt });
549
- await this._arm(expiresAt);
627
+ return this._serial(async () => {
628
+ const epoch = this._epoch;
629
+ const record = await this._read();
630
+ if (!record || epoch !== this._epoch) return;
631
+ const expiresAt = this._expiry();
632
+ await this._storage.set(this._key, { ...record, expiresAt });
633
+ await this._arm(expiresAt);
634
+ });
550
635
  }
551
636
  get autoLockMs() {
552
637
  return this._autoLockMs;
@@ -561,6 +646,19 @@ var Session = class {
561
646
  this._onLock.add(callback);
562
647
  return () => this._onLock.delete(callback);
563
648
  }
649
+ _serial(fn) {
650
+ const run = this._queue.then(fn);
651
+ this._queue = run.catch(() => void 0);
652
+ return run;
653
+ }
654
+ /** The lock itself; callers are already in the queue. */
655
+ async _lockNow() {
656
+ this._epoch++;
657
+ const was = await this._storage.get(this._key);
658
+ await this._storage.remove(this._key);
659
+ await this._alarms.clear(this._alarm);
660
+ if (was !== void 0 && was !== null) for (const cb of this._onLock) cb();
661
+ }
564
662
  _expiry() {
565
663
  return this._autoLockMs > 0 ? this._now() + this._autoLockMs : Number.POSITIVE_INFINITY;
566
664
  }
@@ -572,7 +670,7 @@ var Session = class {
572
670
  const raw = await this._storage.get(this._key);
573
671
  if (!raw || typeof raw.key !== "string" || typeof raw.expiresAt !== "number") return null;
574
672
  if (raw.expiresAt <= this._now()) {
575
- await this.lock();
673
+ await this._lockNow();
576
674
  return null;
577
675
  }
578
676
  return raw;
@@ -1219,6 +1317,7 @@ function memoryApprovalWindow() {
1219
1317
  }
1220
1318
 
1221
1319
  // src/router.ts
1320
+ var import_core4 = require("@anywalletkit/core");
1222
1321
  var RouterError = class _RouterError extends Error {
1223
1322
  code;
1224
1323
  data;
@@ -1232,11 +1331,35 @@ var RouterError = class _RouterError extends Error {
1232
1331
  return new _RouterError(e.code, e.message, e.data);
1233
1332
  }
1234
1333
  };
1235
- function toRelayError(e) {
1236
- if (e instanceof RouterError) return e.data === void 0 ? { code: e.code, message: e.message } : { code: e.code, message: e.message, data: e.data };
1237
- if (e instanceof ReviewDeclined) return { code: e.code, message: e.message };
1238
- const code = typeof e?.code === "number" ? e.code : RELAY_ERRORS.internal;
1239
- return { code, message: e instanceof Error ? e.message : String(e) };
1334
+ var PROVIDER_CODES = /* @__PURE__ */ new Set([4001, 4100, 4200, 4900, 4901, 4902]);
1335
+ var isNodeCode = (code) => typeof code === "number" && Number.isInteger(code) && (code === 3 || code >= -32768 && code <= -32e3);
1336
+ var URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>()]+/gi;
1337
+ var pageText = (text) => text.replace(URL_RE, "[url]").split("\n")[0].slice(0, 300);
1338
+ var isHexData = (v) => typeof v === "string" && /^0x[0-9a-fA-F]*$/.test(v) && v.length <= 2 + 2 * 8192;
1339
+ function nodeError(e) {
1340
+ for (let cur = e, depth = 0; cur && typeof cur === "object" && depth < 8; cur = cur.cause, depth++) {
1341
+ const o = cur;
1342
+ if (o.name === "RpcRequestError" && isNodeCode(o.code)) {
1343
+ const text = typeof o.details === "string" && o.details ? o.details : typeof o.shortMessage === "string" ? o.shortMessage : "RPC error";
1344
+ return { code: o.code, message: pageText(text), ...isHexData(o.data) ? { data: o.data } : {} };
1345
+ }
1346
+ }
1347
+ if (!(e instanceof Error) && typeof e === "object" && e !== null) {
1348
+ const o = e;
1349
+ if (isNodeCode(o.code) && typeof o.message === "string") return { code: o.code, message: pageText(o.message), ...isHexData(o.data) ? { data: o.data } : {} };
1350
+ }
1351
+ return void 0;
1352
+ }
1353
+ function toRelayError(e, options = {}) {
1354
+ if (e instanceof RouterError) return e.data === void 0 ? { code: e.code, message: pageText(e.message) } : { code: e.code, message: pageText(e.message), data: e.data };
1355
+ if (e instanceof ReviewDeclined) return { code: e.code, message: pageText(e.message) };
1356
+ if (e instanceof import_core4.UserRejectedError) return { code: RELAY_ERRORS.userRejected, message: pageText(e.message) };
1357
+ const node = nodeError(e);
1358
+ if (node) return node;
1359
+ const code = e?.code;
1360
+ if (e instanceof Error && typeof code === "number" && PROVIDER_CODES.has(code)) return { code, message: pageText(e.message) };
1361
+ options.log?.(e);
1362
+ return { code: RELAY_ERRORS.internal, message: "Internal error" };
1240
1363
  }
1241
1364
  var READ_METHODS = /* @__PURE__ */ new Set([
1242
1365
  "eth_blockNumber",
@@ -1489,6 +1612,10 @@ function describeSiwe(text, ctx) {
1489
1612
  }
1490
1613
 
1491
1614
  // src/signing.ts
1615
+ var safeNonce = (n) => {
1616
+ if (n < 0n || n > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`The RPC returned an impossible nonce (${n}).`);
1617
+ return Number(n.toString());
1618
+ };
1492
1619
  var ADDRESS = /^0x[0-9a-fA-F]{40}$/;
1493
1620
  var HEX = /^0x([0-9a-fA-F]{2})*$/;
1494
1621
  var lc = (s) => s.toLowerCase();
@@ -1584,13 +1711,13 @@ async function prepareTransaction(deps, network, from, tx) {
1584
1711
  const base = block?.baseFeePerGas != null ? big(block.baseFeePerGas) : null;
1585
1712
  if (base === null) {
1586
1713
  const gasPrice = big(await deps.rpc(network, "eth_gasPrice", []));
1587
- return { chainId: network.chainId, nonce: Number(big(nonceHex)), gasLimit: hex(gasLimit), gasPrice: hex(gasPrice), maxFee: (gasLimit * gasPrice).toString() };
1714
+ return { chainId: network.chainId, nonce: safeNonce(big(nonceHex)), gasLimit: hex(gasLimit), gasPrice: hex(gasPrice), maxFee: (gasLimit * gasPrice).toString() };
1588
1715
  }
1589
1716
  const floor = deps.tipFloor?.(network.chainId) ?? MIN_TIP_WEI;
1590
1717
  const nodeTip = await deps.rpc(network, "eth_maxPriorityFeePerGas", []).then(big).catch(() => 0n);
1591
1718
  const tip = nodeTip > floor ? nodeTip : floor;
1592
1719
  const maxFeePerGas = base * 2n + tip;
1593
- return { chainId: network.chainId, nonce: Number(big(nonceHex)), gasLimit: hex(gasLimit), maxFeePerGas: hex(maxFeePerGas), maxPriorityFeePerGas: hex(tip), maxFee: (gasLimit * maxFeePerGas).toString() };
1720
+ return { chainId: network.chainId, nonce: safeNonce(big(nonceHex)), gasLimit: hex(gasLimit), maxFeePerGas: hex(maxFeePerGas), maxPriorityFeePerGas: hex(tip), maxFee: (gasLimit * maxFeePerGas).toString() };
1594
1721
  }
1595
1722
  function createSignHandler(deps) {
1596
1723
  return async ({ origin, network, method, params, accounts }) => {