@zerc20/wallet-sdk 0.5.0 → 0.6.0

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/src/wallet.ts CHANGED
@@ -49,9 +49,10 @@ import {
49
49
  import { burnAddressFor, core, initCore } from './core.js';
50
50
  import { type ErrorCode, WalletError, fail, guard } from './errors.js';
51
51
  import { type Network, checkNetwork } from './network.js';
52
+ import { preload } from './prove.js';
52
53
  import { Asp, Indexer, type IndexerStatus, PAGE } from './servers.js';
53
54
  import { type Route, flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.js';
54
- import { registerDeposit, relayerFees, rewatchDeposit } from './services.js';
55
+ import { Relaying, registerDeposit, relayerFees, rewatchDeposit } from './services.js';
55
56
  import {
56
57
  type Destination,
57
58
  type MergeStep,
@@ -59,6 +60,7 @@ import {
59
60
  type Step,
60
61
  advanceSpend,
61
62
  bpsOf,
63
+ cancellable,
62
64
  chosenSettling,
63
65
  coins,
64
66
  grossed,
@@ -124,6 +126,9 @@ export interface OpenOptions {
124
126
  birthday?: bigint | Date;
125
127
  /** The app opening the wallet, when it takes a share of what reaches the deposit addresses it hands out. */
126
128
  referrer?: Referrer;
129
+ /** Takes the wallet from another tab or window that holds it open, whose `taken` aborts, rather than failing with
130
+ * `wallet-in-use`: for a user who opens it here, where the other may be a tab the browser keeps in the background. */
131
+ take?: boolean;
127
132
  }
128
133
 
129
134
  export interface WatchOptions extends Pick<OpenOptions, 'network' | 'persist' | 'birthday'> {
@@ -214,12 +219,33 @@ export interface CoinInfo {
214
219
  /** The deposit address of this wallet's that a deposit came through (its `from`), as `Activity.via` names it: its
215
220
  * `rageExit` goes where `to` says. */
216
221
  via: { depositAddress: Address } | null;
217
- /** For a deposit in `screening`, when the ASP should decide it: an estimate, null until there is one. */
218
- screenedBy: Date | null;
222
+ /** While `screening` or `awaiting-root`, when it should be spendable: the later of its AML check's deadline and when a
223
+ * root should cover it, by how long commitments on its chain usually wait for one. An estimate: null until there is
224
+ * one, and in other states. */
225
+ readyBy: Date | null;
219
226
  /** When it landed: its block's time, null until read. */
220
227
  at: Date | null;
221
228
  }
222
229
 
230
+ /** A coin on its way to spendable by itself: screening or settling, which `readyBy` has an estimate for. */
231
+ const onItsWay = (c: CoinInfo) => c.state === 'screening' || c.state === 'awaiting-root';
232
+
233
+ const total = (coins: CoinInfo[]) => coins.reduce((s, c) => s + c.amount, 0n);
234
+
235
+ /** Of `coins`, the one whose turning spendable makes them `need`, the soonest first; null where they never do, or the
236
+ * one it takes has no estimate. */
237
+ export function waitsOn(coins: CoinInfo[], need: bigint): CoinInfo | null {
238
+ const at = (c: CoinInfo) => c.readyBy?.getTime() ?? Infinity;
239
+ // two coins with no estimate compare as equal, not as NaN
240
+ const soonest = [...coins].sort((a, b) => Math.sign(at(a) - at(b)) || 0);
241
+ let left = need;
242
+ for (const c of soonest) {
243
+ left -= c.amount;
244
+ if (left <= 0n) return c.readyBy ? c : null;
245
+ }
246
+ return null;
247
+ }
248
+
223
249
  export interface DepositInfo {
224
250
  id: string;
225
251
  /** The burn address it was sent to. */
@@ -294,8 +320,15 @@ export interface Operation {
294
320
  /** Whom a deposit pays, when `deposit({ to })` named an address; null otherwise. One to a receive code shows its code
295
321
  * as `via` in `activity()`. */
296
322
  readonly to: Address | null;
323
+ /** Whether `cancel()` can still end it: a send, withdrawal or consolidation that has paid nothing and has no request
324
+ * out to the relayer. It moves during a sync: read it while one runs. */
325
+ readonly cancellable: boolean;
297
326
  /** Syncs until the operation is done; throws if it fails. */
298
327
  wait(): Promise<void>;
328
+ /** Ends it while `cancellable`, and does nothing once it is cancelled: it paid nothing, its notes and deposits are free
329
+ * again, it leaves `operations()` and `activity()`, and `wait()` throws `cancelled`. Throws otherwise, and when the
330
+ * wallet cannot save it, which leaves it under way. */
331
+ cancel(): Promise<void>;
299
332
  }
300
333
 
301
334
  export class Wallet {
@@ -305,23 +338,37 @@ export class Wallet {
305
338
  #queue: Promise<unknown> = Promise.resolve();
306
339
  /** Calls running or queued. */
307
340
  #busy = 0;
308
- #release = () => {};
341
+ readonly #lock: Hold;
309
342
  #referrer: Referrer | undefined;
310
343
  readonly #unlock: (() => Promise<Uint8Array>) | undefined;
311
344
  #readOnly = false;
312
345
  #closed = new AbortController();
313
346
 
314
- private constructor(ctx: Ctx, unlock?: () => Promise<Uint8Array>) {
347
+ /** A wallet over `ctx`, held by `lock`: by default held nowhere, as where there are no Web Locks. */
348
+ private constructor(
349
+ ctx: Ctx,
350
+ unlock?: () => Promise<Uint8Array>,
351
+ lock: Hold = { release: () => {}, taken: new AbortController().signal, guard: (f) => f() },
352
+ ) {
315
353
  this.#ctx = ctx;
316
354
  this.#unlock = unlock;
355
+ this.#lock = lock;
356
+ lock.taken.addEventListener('abort', () => this.#closed.abort(lock.taken.reason));
317
357
  }
318
358
 
319
- /** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
359
+ /** Lets this wallet go, for another tab or another account here. This object is done with: what is
320
360
  * queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
321
361
  * ends, so that two never write its state at once. */
322
362
  close(): Promise<void> {
323
363
  this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
324
- return this.#queue.then(() => this.#release());
364
+ return this.#queue.then(() => this.#lock.release());
365
+ }
366
+
367
+ /** Aborts once another tab or window takes this wallet (`take`), which closes it here: what is queued fails with
368
+ * `wallet-taken`, a burn address search stops, and the task under way saves nothing more, as the other tab reads the
369
+ * state and writes it now. */
370
+ get taken(): AbortSignal {
371
+ return this.#lock.taken;
325
372
  }
326
373
 
327
374
  get address(): Address {
@@ -358,16 +405,16 @@ export class Wallet {
358
405
  const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
359
406
  if (seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
360
407
  await initCore();
361
- const release = await lockWallet(account.address);
408
+ const hold = await lockWallet(account.address, 'wallet', opts.take);
362
409
  try {
363
- return await Wallet.#open(opts, seed, account, release);
410
+ return await Wallet.#open(opts, seed, account, hold);
364
411
  } catch (e) {
365
- release();
412
+ hold.release();
366
413
  throw e;
367
414
  }
368
415
  }
369
416
 
370
- static async #open(opts: OpenOptions, seed: Uint8Array, account: Account, release: () => void): Promise<Wallet> {
417
+ static async #open(opts: OpenOptions, seed: Uint8Array, account: Account, hold: Hold): Promise<Wallet> {
371
418
  const address = account.address;
372
419
  const chains = chainsOf(opts.network);
373
420
  const code = await chains[0]?.chain.client.getCode({ address });
@@ -384,7 +431,7 @@ export class Wallet {
384
431
  );
385
432
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
386
433
  const publicKey = hexToBytes((core.spendKey(seed) as SpendKey).publicKey);
387
- const saved = await opts.persist.load();
434
+ const saved = await hold.guard(() => opts.persist.load());
388
435
  // registered already: used before, here or on another device, so its past is looked for
389
436
  // ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
390
437
  const restoring =
@@ -403,10 +450,9 @@ export class Wallet {
403
450
  view,
404
451
  chains,
405
452
  },
406
- { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday },
453
+ { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday, hold },
407
454
  typeof opts.seed === 'function' ? opts.seed : undefined,
408
455
  );
409
- wallet.#release = release;
410
456
  wallet.#referrer = opts.referrer;
411
457
  return wallet;
412
458
  }
@@ -419,7 +465,7 @@ export class Wallet {
419
465
  await initCore();
420
466
  const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
421
467
  const address = getAddress(bytesToHex(view.address));
422
- const release = await lockWallet(address, 'view');
468
+ const hold = await lockWallet(address, 'view');
423
469
  try {
424
470
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
425
471
  // what the account signed: a nullifying key that is not the account's would show spent coins as unspent
@@ -443,21 +489,22 @@ export class Wallet {
443
489
  // registered, so its past is looked for
444
490
  {
445
491
  persist: opts.persist,
446
- saved: await opts.persist.load(),
492
+ saved: await hold.guard(() => opts.persist.load()),
447
493
  key: stateKey(view.seed),
448
494
  from: opts.birthday ?? 0n,
495
+ hold,
449
496
  },
450
497
  );
451
- wallet.#release = release;
452
498
  wallet.#readOnly = true;
453
499
  return wallet;
454
500
  } catch (e) {
455
- release();
501
+ hold.release();
456
502
  throw e;
457
503
  }
458
504
  }
459
505
 
460
- /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
506
+ /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date,
507
+ * as long as `hold` holds it. */
461
508
  static async #begin(
462
509
  base: Omit<Ctx, 'state' | 'save' | 'lastFullRefreshMs'>,
463
510
  {
@@ -465,7 +512,14 @@ export class Wallet {
465
512
  saved,
466
513
  key,
467
514
  from,
468
- }: { persist: OpenOptions['persist']; saved: Uint8Array | null | undefined; key: Uint8Array; from?: bigint | Date },
515
+ hold,
516
+ }: {
517
+ persist: OpenOptions['persist'];
518
+ saved: Uint8Array | null | undefined;
519
+ key: Uint8Array;
520
+ from?: bigint | Date;
521
+ hold: Hold;
522
+ },
469
523
  unlock?: () => Promise<Uint8Array>,
470
524
  ): Promise<Wallet> {
471
525
  const loaded = saved?.length ? openState(key, saved) : null;
@@ -479,14 +533,17 @@ export class Wallet {
479
533
  if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
480
534
  fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
481
535
  const state: WalletState = loaded ?? emptyWalletState(base.address);
482
- const ctx: Ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
536
+ const save = () => hold.guard(() => persist.save(sealState(key, state)));
537
+ const ctx: Ctx = { ...base, state, save, lastFullRefreshMs: 0 };
483
538
  if (!loaded) await start(ctx, from);
484
- const wallet = new Wallet(ctx, unlock);
539
+ const wallet = new Wallet(ctx, unlock, hold);
485
540
  // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
486
541
  // and history work before the first sync
487
542
  const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
488
543
  wallet.#tokens = tokens.flat();
489
544
  keyAll(ctx, wallet.#tokens);
545
+ // taken while it opened, before it listened
546
+ hold.taken.throwIfAborted();
490
547
  return wallet;
491
548
  }
492
549
 
@@ -586,6 +643,9 @@ export class Wallet {
586
643
  if (op.kind === 'deposit') await settleDeposit(this.#ctx, op);
587
644
  delete op.retrying;
588
645
  } catch (e) {
646
+ // cancelled while it ran: it stays so. And a relayer relaying its request already answers the request that
647
+ // started it, so whether a service keeps failing stays as the tries before found it.
648
+ if (op.status !== 'pending' || e instanceof Relaying) continue;
589
649
  // only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
590
650
  // error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
591
651
  // kept on the operation, so that one that never gets past it does not look merely slow.
@@ -676,6 +736,12 @@ export class Wallet {
676
736
  coins(): CoinInfo[] {
677
737
  const size = (chainId: bigint) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
678
738
  const unproved = (chainId: bigint) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
739
+ // when a root should cover a coin that landed at `atMs`
740
+ const covered = (chainId: bigint, atMs: number | undefined) => {
741
+ const secs = this.#status.get(chainId)?.settleSecs ?? null;
742
+ return atMs === undefined || secs === null ? null : atMs + secs * 1000;
743
+ };
744
+ const date = (ms: number | null) => (ms === null ? null : new Date(ms));
679
745
  const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
680
746
  // spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
681
747
  // nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
@@ -703,7 +769,7 @@ export class Wallet {
703
769
  state,
704
770
  from: n.peer,
705
771
  via: null,
706
- screenedBy: null,
772
+ readyBy: state === 'awaiting-root' ? date(covered(n.chainId, f.atMs)) : null,
707
773
  at: f.atMs === undefined ? null : new Date(f.atMs),
708
774
  });
709
775
  }
@@ -723,7 +789,14 @@ export class Wallet {
723
789
  : f.index >= size(d.chainId)
724
790
  ? unproved(d.chainId)
725
791
  : 'spendable';
726
- const screenedBy = state === 'screening' && f.decidedByMs !== undefined ? new Date(f.decidedByMs) : null;
792
+ // a root covers a deposit while it is screened, most often before its check ends
793
+ const settles = covered(d.chainId, f.atMs);
794
+ const readyBy =
795
+ state === 'screening'
796
+ ? date(f.decidedByMs === undefined ? null : Math.max(f.decidedByMs, settles ?? 0))
797
+ : state === 'awaiting-root'
798
+ ? date(settles)
799
+ : null;
727
800
  out.push({
728
801
  id,
729
802
  kind: 'deposit',
@@ -732,7 +805,7 @@ export class Wallet {
732
805
  state,
733
806
  from: f.sender,
734
807
  via: this.#via(d, f.sender),
735
- screenedBy,
808
+ readyBy,
736
809
  at: f.atMs === undefined ? null : new Date(f.atMs),
737
810
  });
738
811
  }
@@ -1077,6 +1150,9 @@ export class Wallet {
1077
1150
  return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
1078
1151
  },
1079
1152
  to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
1153
+ get cancellable() {
1154
+ return cancellable(op);
1155
+ },
1080
1156
  wait: async () => {
1081
1157
  for (;;) {
1082
1158
  if (op.status === 'done') return;
@@ -1085,6 +1161,31 @@ export class Wallet {
1085
1161
  if (op.status === 'pending') await new Promise((r) => setTimeout(r, 15_000));
1086
1162
  }
1087
1163
  },
1164
+ // at once, outside the queue: a sync under way may wait long on a service that does not answer, and a page closed
1165
+ // before a queued save would find the operation pending and relay it. That sync starts no further stage of it.
1166
+ cancel: async () => {
1167
+ if (op.errorCode === 'cancelled') return;
1168
+ if (!cancellable(op))
1169
+ fail(
1170
+ 'invalid',
1171
+ op.status === 'pending'
1172
+ ? 'it has a request out to the relayer, or has paid: it finishes or fails on its own'
1173
+ : `it is ${op.status}`,
1174
+ );
1175
+ const ops = this.#ctx.state.ops;
1176
+ const at = ops.indexOf(op);
1177
+ if (at < 0) fail('invalid', 'this wallet no longer holds it');
1178
+ ops.splice(at, 1);
1179
+ Object.assign(op, { status: 'failed', errorCode: 'cancelled', error: 'cancelled' });
1180
+ try {
1181
+ await this.#ctx.save();
1182
+ } catch (e) {
1183
+ // not saved, so not cancelled: the saved state would resume it
1184
+ Object.assign(op, { status: 'pending', errorCode: undefined, error: undefined });
1185
+ ops.splice(at, 0, op);
1186
+ throw e;
1187
+ }
1188
+ },
1088
1189
  };
1089
1190
  }
1090
1191
 
@@ -1417,9 +1518,14 @@ export class Wallet {
1417
1518
  if (opts.kind === 'consolidate') {
1418
1519
  const chosen = opts.coins && this.#chosen(token, opts.coins);
1419
1520
  if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
1420
- fail(
1521
+ // all of them must be spendable: the last one on its way, where each is a coin with an estimate
1522
+ const ids = new Set(opts.coins);
1523
+ const picked = this.coins().filter((c) => ids.has(c.id));
1524
+ const waiting = picked.filter((c) => c.state !== 'spendable');
1525
+ throw new WalletError(
1421
1526
  'funds-settling',
1422
1527
  'a chosen note or deposit is still settling: quote again once coins() shows it spendable',
1528
+ picked.length === ids.size && waiting.every(onItsWay) ? waitsOn(waiting, total(waiting)) : null,
1423
1529
  );
1424
1530
  }
1425
1531
  const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
@@ -1445,10 +1551,24 @@ export class Wallet {
1445
1551
  const onTheWay =
1446
1552
  this.#ctx.state.ops.some((o) => o.status === 'pending') ||
1447
1553
  settling(this.#ctx, token.chainId, token.tokenId, size);
1448
- payWith(all, amount + relayer + protocol, relayer, onTheWay);
1554
+ const need = amount + relayer + protocol;
1555
+ try {
1556
+ payWith(all, need, relayer, onTheWay);
1557
+ } catch (e) {
1558
+ if (!(e instanceof WalletError) || e.code !== 'funds-settling') throw e;
1559
+ throw new WalletError(e.code, e.message, this.#waitsFor(token, need));
1560
+ }
1449
1561
  return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
1450
1562
  }
1451
1563
 
1564
+ /** Of `token`'s coins on their way, the one whose turning spendable lets a payment of `need` go, as
1565
+ * `WalletError.waitsOn` tells. None while an operation is in flight, as its change is not a coin yet. */
1566
+ #waitsFor(token: Token, need: bigint): CoinInfo | null {
1567
+ if (this.#ctx.state.ops.some((o) => o.status === 'pending')) return null;
1568
+ const mine = this.coins().filter((c) => c.token.chainId === token.chainId && c.token.tokenId === token.tokenId);
1569
+ return waitsOn(mine.filter(onItsWay), need - total(mine.filter((c) => c.state === 'spendable')));
1570
+ }
1571
+
1452
1572
  /** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
1453
1573
  * the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
1454
1574
  async maxAmount(opts: { kind: 'send' | 'withdraw'; token: Token }): Promise<MaxAmount> {
@@ -1480,6 +1600,12 @@ export class Wallet {
1480
1600
  return merge && merge.map((m) => `${token.chainId}:${m.index}`);
1481
1601
  }
1482
1602
 
1603
+ /** Fetches what a send or withdrawal proves with, some 25 MB, once a page: an app calls it as a payment form opens,
1604
+ * so that the payment's proof does not wait for the download. Outside the queue of calls: a sync goes on meanwhile. */
1605
+ preloadProver(): Promise<void> {
1606
+ return preload(this.#ctx.network.circuits.joinsplit);
1607
+ }
1608
+
1483
1609
  /** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
1484
1610
  * zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
1485
1611
  * `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
@@ -1595,22 +1721,50 @@ const chainsOf = (network: Network): ChainCtx[] =>
1595
1721
  asp: new Asp(config.asp),
1596
1722
  }));
1597
1723
 
1598
- /** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
1599
- * other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
1600
- * Nothing where there are no Web Locks. */
1601
- export async function lockWallet(address: Address, kind: 'wallet' | 'view' = 'wallet'): Promise<() => void> {
1724
+ /** A wallet held open here (`lockWallet`): until `release`, or until another tab or window takes it, which aborts
1725
+ * `taken`. */
1726
+ export interface Hold {
1727
+ release: () => void;
1728
+ taken: AbortSignal;
1729
+ /** Runs `f`, a read or a write of the wallet's saved state, with no other tab in one, and only while this one holds the
1730
+ * wallet: asked of the browser's lock manager, which knows of a take before this tab is told, so that a write lands
1731
+ * either before the tab that took the wallet reads the state, or not at all. */
1732
+ guard: <T>(f: () => Promise<T>) => Promise<T>;
1733
+ }
1734
+
1735
+ /** Holds `address`'s wallet: two tabs on one wallet would each overwrite the other's state and spend the same notes. It
1736
+ * fails with `wallet-in-use` while another tab or window holds it, unless it `take`s it from that one. A wallet that
1737
+ * watches it (`view`) is held apart, as its state is its own. Neither where there are no Web Locks. */
1738
+ export async function lockWallet(address: Address, kind: 'wallet' | 'view' = 'wallet', take = false): Promise<Hold> {
1739
+ const taken = new AbortController();
1602
1740
  const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
1603
- if (!locks) return () => {};
1741
+ if (!locks) return { release: () => {}, taken: taken.signal, guard: (f) => f() };
1742
+ const name = `zerc20-${kind}:${address.toLowerCase()}`;
1743
+ const lose = () => taken.abort(new WalletError('wallet-taken', 'this wallet was opened in another tab or window'));
1604
1744
  let release = () => {};
1605
1745
  const held = new Promise<void>((r) => (release = r));
1606
- const got = await new Promise<boolean>((resolve) => {
1607
- void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1608
- resolve(lock !== null);
1609
- return lock ? held : undefined;
1610
- });
1746
+ let got = false;
1747
+ await new Promise<void>((resolve, reject) => {
1748
+ locks
1749
+ .request(name, take ? { steal: true } : { ifAvailable: true }, (lock) => {
1750
+ got = lock !== null;
1751
+ resolve();
1752
+ return got ? held : undefined;
1753
+ })
1754
+ // a held lock's request rejects as another takes it
1755
+ .catch((e: unknown) => (got ? lose() : reject(e)));
1611
1756
  });
1612
1757
  if (!got) fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
1613
- return release;
1758
+ const state = `${name}/state`;
1759
+ const guard = <T>(f: () => Promise<T>) =>
1760
+ locks.request(state, async () => {
1761
+ const holders = (await locks.query()).held ?? [];
1762
+ const holder = (n: string) => holders.find((l) => l.name === n)?.clientId;
1763
+ if (holder(name) !== holder(state)) lose();
1764
+ taken.signal.throwIfAborted();
1765
+ return f();
1766
+ });
1767
+ return { release, taken: taken.signal, guard };
1614
1768
  }
1615
1769
 
1616
1770
  /** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or