@zerc20/wallet-sdk 0.4.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.
Files changed (73) hide show
  1. package/LICENSE +17 -20
  2. package/README.md +2 -2
  3. package/dist/accountId.d.ts +5 -0
  4. package/dist/accountId.js +19 -0
  5. package/dist/accountId.js.map +1 -0
  6. package/dist/chain.d.ts +86 -9
  7. package/dist/chain.js +61 -33
  8. package/dist/chain.js.map +1 -1
  9. package/dist/context.d.ts +8 -2
  10. package/dist/context.js +15 -2
  11. package/dist/context.js.map +1 -1
  12. package/dist/core.d.ts +35 -5
  13. package/dist/core.js +98 -25
  14. package/dist/core.js.map +1 -1
  15. package/dist/errors.d.ts +20 -3
  16. package/dist/errors.js +7 -1
  17. package/dist/errors.js.map +1 -1
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +4 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/network.d.ts +4 -2
  22. package/dist/network.js +3 -1
  23. package/dist/network.js.map +1 -1
  24. package/dist/pos.d.ts +10 -1
  25. package/dist/pos.js +219 -55
  26. package/dist/pos.js.map +1 -1
  27. package/dist/prove.d.ts +3 -0
  28. package/dist/prove.js +9 -0
  29. package/dist/prove.js.map +1 -1
  30. package/dist/rageExit.d.ts +27 -4
  31. package/dist/rageExit.js +160 -49
  32. package/dist/rageExit.js.map +1 -1
  33. package/dist/seed.d.ts +4 -0
  34. package/dist/seed.js +13 -1
  35. package/dist/seed.js.map +1 -1
  36. package/dist/servers.d.ts +5 -0
  37. package/dist/servers.js +2 -0
  38. package/dist/servers.js.map +1 -1
  39. package/dist/services.d.ts +14 -6
  40. package/dist/services.js +35 -14
  41. package/dist/services.js.map +1 -1
  42. package/dist/spend.d.ts +53 -11
  43. package/dist/spend.js +171 -65
  44. package/dist/spend.js.map +1 -1
  45. package/dist/state.d.ts +21 -0
  46. package/dist/state.js.map +1 -1
  47. package/dist/sync.d.ts +10 -5
  48. package/dist/sync.js +49 -18
  49. package/dist/sync.js.map +1 -1
  50. package/dist/wallet.d.ts +132 -21
  51. package/dist/wallet.js +342 -91
  52. package/dist/wallet.js.map +1 -1
  53. package/package.json +3 -3
  54. package/src/accountId.ts +22 -0
  55. package/src/chain.ts +101 -49
  56. package/src/context.ts +27 -3
  57. package/src/core.ts +112 -26
  58. package/src/errors.ts +15 -2
  59. package/src/index.ts +15 -2
  60. package/src/network.ts +6 -3
  61. package/src/pos.ts +247 -61
  62. package/src/prove.ts +10 -0
  63. package/src/rageExit.ts +237 -61
  64. package/src/seed.ts +24 -1
  65. package/src/servers.ts +9 -1
  66. package/src/services.ts +36 -22
  67. package/src/spend.ts +188 -75
  68. package/src/state.ts +25 -1
  69. package/src/sync.ts +54 -19
  70. package/src/wallet.ts +437 -109
  71. package/wasm/zerc20_wallet_core.d.ts +1 -1
  72. package/wasm/zerc20_wallet_core.js +1 -1
  73. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
package/src/wallet.ts CHANGED
@@ -27,10 +27,12 @@ import {
27
27
  getAddress,
28
28
  hexToBytes,
29
29
  isAddress,
30
+ isAddressEqual,
30
31
  numberToHex,
31
32
  zeroAddress,
32
33
  } from 'viem';
33
34
  import { toAccount } from 'viem/accounts';
35
+ import { idAccount } from './accountId.js';
34
36
  import { Chain, MAX_REFERRAL_BPS, NATIVE, type Referrer, type Token, erc20Abi, tokenAbi } from './chain.js';
35
37
  import {
36
38
  type ChainCtx,
@@ -39,6 +41,7 @@ import {
39
41
  chainCtx,
40
42
  chainState,
41
43
  owner,
44
+ refundSigner,
42
45
  switched,
43
46
  walletOn,
44
47
  walletSees,
@@ -46,15 +49,18 @@ import {
46
49
  import { burnAddressFor, core, initCore } from './core.js';
47
50
  import { type ErrorCode, WalletError, fail, guard } from './errors.js';
48
51
  import { type Network, checkNetwork } from './network.js';
52
+ import { preload } from './prove.js';
49
53
  import { Asp, Indexer, type IndexerStatus, PAGE } from './servers.js';
50
- import { rageExit } from './rageExit.js';
51
- import { registerDeposit, relayerFees } from './services.js';
54
+ import { type Route, flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.js';
55
+ import { Relaying, registerDeposit, relayerFees, rewatchDeposit } from './services.js';
52
56
  import {
53
57
  type Destination,
54
58
  type MergeStep,
55
59
  type SpendData,
56
60
  type Step,
57
61
  advanceSpend,
62
+ bpsOf,
63
+ cancellable,
58
64
  chosenSettling,
59
65
  coins,
60
66
  grossed,
@@ -70,7 +76,9 @@ import {
70
76
  withdrawalOf,
71
77
  } from './spend.js';
72
78
  import {
79
+ type DepositAddressReceipt,
73
80
  type DepositOperation,
81
+ type DepositRecord,
74
82
  type OperationRecord,
75
83
  type WalletState,
76
84
  emptyWalletState,
@@ -118,6 +126,9 @@ export interface OpenOptions {
118
126
  birthday?: bigint | Date;
119
127
  /** The app opening the wallet, when it takes a share of what reaches the deposit addresses it hands out. */
120
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;
121
132
  }
122
133
 
123
134
  export interface WatchOptions extends Pick<OpenOptions, 'network' | 'persist' | 'birthday'> {
@@ -170,7 +181,8 @@ export interface Activity {
170
181
  * paid none. */
171
182
  fee: bigint | null;
172
183
  /** The other side, where known: a payer's claimed address (unverified), where a payment or withdrawal went, or whom a
173
- * returned deposit went back to. */
184
+ * returned deposit went back to. A payment's other side, `sent`'s or that of a `received` with no `burnAddress`, is a
185
+ * zERC20 account, which an app shows by its zERC20 ID (`accountId`); any other is a public address. */
174
186
  counterparty: Address | null;
175
187
  /** What the counterparty alone does not say: the receive code a payment went to (it has no counterparty), or the
176
188
  * deposit address a deposit came through (its counterparty, which forwarded what was sent to it). */
@@ -204,8 +216,34 @@ export interface CoinInfo {
204
216
  state: 'spendable' | Exclude<PendingReason, 'incoming'> | 'spending' | 'blocked';
205
217
  /** A note's payer (as claimed, unverified) or a deposit's sender, where known. */
206
218
  from: Address | null;
207
- /** For a deposit in `screening`, when the ASP should decide it: an estimate, null until there is one. */
208
- screenedBy: Date | null;
219
+ /** The deposit address of this wallet's that a deposit came through (its `from`), as `Activity.via` names it: its
220
+ * `rageExit` goes where `to` says. */
221
+ via: { depositAddress: Address } | 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;
226
+ /** When it landed: its block's time, null until read. */
227
+ at: Date | null;
228
+ }
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;
209
247
  }
210
248
 
211
249
  export interface DepositInfo {
@@ -221,12 +259,25 @@ export interface DepositInfo {
221
259
  state: 'screening' | 'approved' | 'rejected' | 'spent';
222
260
  }
223
261
 
262
+ export interface DepositAddressInfo {
263
+ address: Address;
264
+ token: Token;
265
+ /** What the relayer takes out of each payment through it. Null where its receipt in storage does not say what it was
266
+ * registered with: `flushDepositAddress` needs that, so only the relayer flushes such an address. */
267
+ fee: bigint | null;
268
+ /** When it was handed out, or watched again (`rewatchDepositAddress`), from which the deposit relayer watches it for
269
+ * 7 days: when storage took its receipt. */
270
+ at: Date;
271
+ }
272
+
224
273
  /** A send's or withdrawal's recipient gets all of its amount, and the private balance pays `fee` on top. */
225
274
  export interface FeeQuote {
226
275
  /** To the relayer. */
227
276
  relayer: bigint;
228
277
  /** The protocol's cut of the public withdrawal: a withdrawal's, or the one that pays the relayer. */
229
278
  protocol: bigint;
279
+ /** `protocol` as a rate of that withdrawal, in basis points (35 is 0.35%): lower in tiers as the withdrawal grows. */
280
+ protocolBps: number;
230
281
  /** `relayer + protocol`: the `maxFee` this quote passes. */
231
282
  fee: bigint;
232
283
  /** Transactions needed: one JoinSplit for a send or a withdrawal, a consolidation's merges (each pays the relayer's
@@ -234,6 +285,16 @@ export interface FeeQuote {
234
285
  steps: number;
235
286
  }
236
287
 
288
+ /** The most a send or withdrawal can pay now, and what its fees keep back of what is spendable. */
289
+ export interface MaxAmount {
290
+ /** What the recipient would get: 0 where the fees take everything. */
291
+ amount: bigint;
292
+ /** The fees: the payment's, and those of the merges it needs first. Where they take everything, what they would be. */
293
+ fee: bigint;
294
+ /** Merges first, when one JoinSplit cannot spend all of it: each pays the relayer again. */
295
+ merges: number;
296
+ }
297
+
237
298
  export type OperationKind = OperationRecord['kind'];
238
299
  export type OperationStatus = OperationRecord['status'];
239
300
  export type CoinState = CoinInfo['state'];
@@ -256,10 +317,18 @@ export interface Operation {
256
317
  /** How far a send, withdrawal or consolidation round has come, for an app to show; null for a deposit, or between
257
318
  * rounds. It moves during a sync: read it while one runs. */
258
319
  readonly stage: Step['stage'] | MergeStep['stage'] | null;
259
- /** Whom a deposit pays, when it goes into their burn address (`deposit({ to })`); null otherwise. */
320
+ /** Whom a deposit pays, when `deposit({ to })` named an address; null otherwise. One to a receive code shows its code
321
+ * as `via` in `activity()`. */
260
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;
261
326
  /** Syncs until the operation is done; throws if it fails. */
262
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>;
263
332
  }
264
333
 
265
334
  export class Wallet {
@@ -269,23 +338,37 @@ export class Wallet {
269
338
  #queue: Promise<unknown> = Promise.resolve();
270
339
  /** Calls running or queued. */
271
340
  #busy = 0;
272
- #release = () => {};
341
+ readonly #lock: Hold;
273
342
  #referrer: Referrer | undefined;
274
343
  readonly #unlock: (() => Promise<Uint8Array>) | undefined;
275
344
  #readOnly = false;
276
345
  #closed = new AbortController();
277
346
 
278
- 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
+ ) {
279
353
  this.#ctx = ctx;
280
354
  this.#unlock = unlock;
355
+ this.#lock = lock;
356
+ lock.taken.addEventListener('abort', () => this.#closed.abort(lock.taken.reason));
281
357
  }
282
358
 
283
- /** 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
284
360
  * queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
285
361
  * ends, so that two never write its state at once. */
286
362
  close(): Promise<void> {
287
363
  this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
288
- 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;
289
372
  }
290
373
 
291
374
  get address(): Address {
@@ -322,16 +405,16 @@ export class Wallet {
322
405
  const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
323
406
  if (seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
324
407
  await initCore();
325
- const release = await lockWallet(account.address);
408
+ const hold = await lockWallet(account.address, 'wallet', opts.take);
326
409
  try {
327
- return await Wallet.#open(opts, seed, account, release);
410
+ return await Wallet.#open(opts, seed, account, hold);
328
411
  } catch (e) {
329
- release();
412
+ hold.release();
330
413
  throw e;
331
414
  }
332
415
  }
333
416
 
334
- 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> {
335
418
  const address = account.address;
336
419
  const chains = chainsOf(opts.network);
337
420
  const code = await chains[0]?.chain.client.getCode({ address });
@@ -348,7 +431,7 @@ export class Wallet {
348
431
  );
349
432
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
350
433
  const publicKey = hexToBytes((core.spendKey(seed) as SpendKey).publicKey);
351
- const saved = await opts.persist.load();
434
+ const saved = await hold.guard(() => opts.persist.load());
352
435
  // registered already: used before, here or on another device, so its past is looked for
353
436
  // ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
354
437
  const restoring =
@@ -359,6 +442,7 @@ export class Wallet {
359
442
  network: opts.network,
360
443
  seed,
361
444
  owners: new Map(),
445
+ refundSigners: new Map(),
362
446
  address,
363
447
  account,
364
448
  walletClient: opts.walletClient ?? null,
@@ -366,10 +450,9 @@ export class Wallet {
366
450
  view,
367
451
  chains,
368
452
  },
369
- { 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 },
370
454
  typeof opts.seed === 'function' ? opts.seed : undefined,
371
455
  );
372
- wallet.#release = release;
373
456
  wallet.#referrer = opts.referrer;
374
457
  return wallet;
375
458
  }
@@ -382,7 +465,7 @@ export class Wallet {
382
465
  await initCore();
383
466
  const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
384
467
  const address = getAddress(bytesToHex(view.address));
385
- const release = await lockWallet(address, 'view');
468
+ const hold = await lockWallet(address, 'view');
386
469
  try {
387
470
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
388
471
  // what the account signed: a nullifying key that is not the account's would show spent coins as unspent
@@ -394,6 +477,7 @@ export class Wallet {
394
477
  network: opts.network,
395
478
  seed: null,
396
479
  owners: new Map([['', owner]]),
480
+ refundSigners: new Map(),
397
481
  address,
398
482
  // signs nothing: every call that would send is refused first
399
483
  account: toAccount(address),
@@ -405,21 +489,22 @@ export class Wallet {
405
489
  // registered, so its past is looked for
406
490
  {
407
491
  persist: opts.persist,
408
- saved: await opts.persist.load(),
492
+ saved: await hold.guard(() => opts.persist.load()),
409
493
  key: stateKey(view.seed),
410
494
  from: opts.birthday ?? 0n,
495
+ hold,
411
496
  },
412
497
  );
413
- wallet.#release = release;
414
498
  wallet.#readOnly = true;
415
499
  return wallet;
416
500
  } catch (e) {
417
- release();
501
+ hold.release();
418
502
  throw e;
419
503
  }
420
504
  }
421
505
 
422
- /** 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. */
423
508
  static async #begin(
424
509
  base: Omit<Ctx, 'state' | 'save' | 'lastFullRefreshMs'>,
425
510
  {
@@ -427,7 +512,14 @@ export class Wallet {
427
512
  saved,
428
513
  key,
429
514
  from,
430
- }: { 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
+ },
431
523
  unlock?: () => Promise<Uint8Array>,
432
524
  ): Promise<Wallet> {
433
525
  const loaded = saved?.length ? openState(key, saved) : null;
@@ -441,14 +533,17 @@ export class Wallet {
441
533
  if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
442
534
  fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
443
535
  const state: WalletState = loaded ?? emptyWalletState(base.address);
444
- 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 };
445
538
  if (!loaded) await start(ctx, from);
446
- const wallet = new Wallet(ctx, unlock);
539
+ const wallet = new Wallet(ctx, unlock, hold);
447
540
  // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
448
541
  // and history work before the first sync
449
542
  const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
450
543
  wallet.#tokens = tokens.flat();
451
544
  keyAll(ctx, wallet.#tokens);
545
+ // taken while it opened, before it listened
546
+ hold.taken.throwIfAborted();
452
547
  return wallet;
453
548
  }
454
549
 
@@ -539,14 +634,8 @@ export class Wallet {
539
634
  async #catchUp(): Promise<void> {
540
635
  const errors = await syncAll(this.#ctx);
541
636
  errors.push(...(await this.#readRoots()));
542
- const listed = await Promise.all(
543
- this.#ctx.chains.map((c) =>
544
- c.chain
545
- .tokens()
546
- .catch((e: unknown) => (errors.push(e), this.#tokens.filter((t) => t.chainId === c.config.chainId))),
547
- ),
548
- );
549
- this.#tokens = listed.flat();
637
+ // as syncAll read them: a chain it could not read keeps those read before
638
+ this.#tokens = this.#ctx.chains.flatMap((c) => c.chain.known);
550
639
  for (const op of this.#ctx.state.ops.filter((o) => o.status === 'pending')) {
551
640
  try {
552
641
  if (op.kind === 'send' || op.kind === 'withdraw' || op.kind === 'consolidate')
@@ -554,6 +643,9 @@ export class Wallet {
554
643
  if (op.kind === 'deposit') await settleDeposit(this.#ctx, op);
555
644
  delete op.retrying;
556
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;
557
649
  // only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
558
650
  // error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
559
651
  // kept on the operation, so that one that never gets past it does not look merely slow.
@@ -644,6 +736,12 @@ export class Wallet {
644
736
  coins(): CoinInfo[] {
645
737
  const size = (chainId: bigint) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
646
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));
647
745
  const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
648
746
  // spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
649
747
  // nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
@@ -670,7 +768,9 @@ export class Wallet {
670
768
  amount: n.value,
671
769
  state,
672
770
  from: n.peer,
673
- screenedBy: null,
771
+ via: null,
772
+ readyBy: state === 'awaiting-root' ? date(covered(n.chainId, f.atMs)) : null,
773
+ at: f.atMs === undefined ? null : new Date(f.atMs),
674
774
  });
675
775
  }
676
776
  }
@@ -689,7 +789,14 @@ export class Wallet {
689
789
  : f.index >= size(d.chainId)
690
790
  ? unproved(d.chainId)
691
791
  : 'spendable';
692
- 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;
693
800
  out.push({
694
801
  id,
695
802
  kind: 'deposit',
@@ -697,7 +804,9 @@ export class Wallet {
697
804
  amount: f.amount!,
698
805
  state,
699
806
  from: f.sender,
700
- screenedBy,
807
+ via: this.#via(d, f.sender),
808
+ readyBy,
809
+ at: f.atMs === undefined ? null : new Date(f.atMs),
701
810
  });
702
811
  }
703
812
  }
@@ -771,27 +880,17 @@ export class Wallet {
771
880
  }
772
881
  }
773
882
  for (const d of s.deposits) {
774
- // registered on this device, or on another, which says so in storage
775
- const registered =
776
- d.depositAddress ??
777
- s.receipts.flatMap((r) =>
778
- r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
779
- ? [r.depositAddress]
780
- : [],
781
- )[0];
782
883
  // on chain once found; shown once its commitment is read, which names the amount
783
884
  for (const f of d.found.filter((f) => f.amount !== null)) {
784
- const forwarded = registered && f.sender?.toLowerCase() === registered.toLowerCase();
785
885
  // into a burn address a payer chose: theirs to this wallet, as a payment is
786
886
  const kind = d.managed ? 'sale' : d.payer ? 'received' : 'deposited';
787
- const via = forwarded ? { depositAddress: registered } : null;
788
887
  const entry = {
789
888
  ...at(kind, d.chainId, f, d.createdMs),
790
889
  token: this.#token(d.chainId, f.tokenId),
791
890
  amount: f.amount!,
792
891
  burnAddress: d.address,
793
892
  };
794
- out.push({ ...entry, fee: null, counterparty: f.sender, via, txHash: null });
893
+ out.push({ ...entry, fee: null, counterparty: f.sender, via: this.#via(d, f.sender), txHash: null });
795
894
  }
796
895
  }
797
896
  // a deposit that paid someone (`deposit({ to })`), once its transfer is found
@@ -836,8 +935,16 @@ export class Wallet {
836
935
  x.address === op.data.burnAddress.toLowerCase() &&
837
936
  x.found.some((f) => f.amount !== null),
838
937
  );
839
- if (!op.data.to || !op.data.txHash || op.status === 'pending' || found) continue;
840
- out.push({ ...base, kind: 'sent', fee: null, counterparty: op.data.to, via: null, txHash: op.data.txHash });
938
+ const { to, code, txHash } = op.data;
939
+ if ((!to && !code) || !txHash || op.status === 'pending' || found) continue;
940
+ out.push({
941
+ ...base,
942
+ kind: 'sent',
943
+ fee: null,
944
+ counterparty: to ?? null,
945
+ via: code ? { code } : null,
946
+ txHash,
947
+ });
841
948
  continue;
842
949
  }
843
950
  // under way (in operations()), or shown by its change
@@ -856,6 +963,21 @@ export class Wallet {
856
963
  return out.sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
857
964
  }
858
965
 
966
+ /** The deposit address `d` registered (here, or on another device, which says so in storage) if `sender` is it: what
967
+ * a deposit from `sender` came through. */
968
+ #via(d: DepositRecord, sender: Hex | null): { depositAddress: Address } | null {
969
+ const registered =
970
+ d.depositAddress ??
971
+ this.#ctx.state.receipts.flatMap((r) =>
972
+ r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
973
+ ? [r.depositAddress]
974
+ : [],
975
+ )[0];
976
+ return registered && sender && isAddressEqual(sender, registered)
977
+ ? { depositAddress: getAddress(registered) }
978
+ : null;
979
+ }
980
+
859
981
  /** The text of a receive code this wallet paid, by its burn address, from storage's record of paying it. */
860
982
  #paidCode(chainId: bigint, burnAddress: Hex): string | null {
861
983
  for (const c of this.#ctx.state.storage?.outgoing.lanes ?? []) {
@@ -900,6 +1022,31 @@ export class Wallet {
900
1022
  );
901
1023
  }
902
1024
 
1025
+ /** The deposit addresses this wallet handed out (`depositAddress`), on any of its devices, newest first: for an app to
1026
+ * show each while what is sent to it is on its way, which `activity()` shows once deposited. Each is dated, and its
1027
+ * fee told, by its receipt in storage, alike on every device. */
1028
+ depositAddresses(): DepositAddressInfo[] {
1029
+ return this.#ctx.state.receipts
1030
+ .flatMap((r) => {
1031
+ if (r.kind !== 'deposit-address') return [];
1032
+ const token = this.#token(r.chainId, r.tokenId);
1033
+ return [{ address: getAddress(r.depositAddress), token, fee: r.args?.fee ?? null, at: new Date(r.atMs) }];
1034
+ })
1035
+ .sort((a, b) => b.at.getTime() - a.at.getTime());
1036
+ }
1037
+
1038
+ /** What `address` is to this wallet on `chainId`: one of its own burn addresses (`deposit`), one it paid someone
1039
+ * into (`payment`, `deposit({ to })` to an address), or neither (null), as is a receive code's that it paid. On chain
1040
+ * either is a plain transfer to an address that names no one: this tells an app listing the account's public
1041
+ * transfers which they were. Both are recorded before the transfer is sent. */
1042
+ burnAddressOf(chainId: bigint, address: Address): { kind: 'deposit' } | { kind: 'payment'; to: Address } | null {
1043
+ const s = this.#ctx.state;
1044
+ const a = address.toLowerCase();
1045
+ if (s.deposits.some((d) => d.chainId === chainId && d.address.toLowerCase() === a)) return { kind: 'deposit' };
1046
+ const paid = s.sent.find((x) => x.chainId === chainId && x.address.toLowerCase() === a);
1047
+ return paid ? { kind: 'payment', to: paid.to } : null;
1048
+ }
1049
+
903
1050
  /** The payments to the burn addresses this wallet's points of sale handed out. Spent ones are, normally, in a
904
1051
  * merged note. */
905
1052
  payments(): DepositInfo[] {
@@ -925,8 +1072,12 @@ export class Wallet {
925
1072
  }
926
1073
 
927
1074
  /** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
928
- * rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
929
- rageExit(id: string): Promise<Hex[]> {
1075
+ * rejected, or when those services stop. The account's wallet sends the transactions and pays their gas, or with
1076
+ * `from` another wallet of the user's does, as `deposit({ from })` pays. One through a deposit address of this
1077
+ * wallet's (`CoinInfo.via`) goes to `to`, by default `from`'s account, else this account; any other goes back to its
1078
+ * sender, and one through a point of sale's deposit address to the refund address its payer named. */
1079
+ rageExit(id: string, opts: { from?: WalletClient; to?: Address } = {}): Promise<Hex[]> {
1080
+ const route = this.#route(opts);
930
1081
  return this.#seeded(async () => {
931
1082
  const [chain, index] = id.split(':');
932
1083
  const record =
@@ -937,10 +1088,39 @@ export class Wallet {
937
1088
  (o) => `${o.chainId}` === chain && inputsOf(o).some((c) => `${c.index}` === index),
938
1089
  );
939
1090
  if (op) fail('invalid', `deposit ${id} is an input of operation ${op.id}, which is in flight`);
940
- return rageExit(this.#ctx, record, Number(index));
1091
+ return rageExit(this.#ctx, record, Number(index), route);
941
1092
  });
942
1093
  }
943
1094
 
1095
+ /** Sends what deposit address `address` of this wallet's holds of `asset` (NATIVE for the chain's own currency) to
1096
+ * `to`: another token sent to it, which it cannot wrap. What it can wrap is wrapped instead, as a payment to it is.
1097
+ * It deploys on its first flush, and refunds nothing before. `from` and `to` as for `rageExit`. */
1098
+ refundDepositAddress(
1099
+ address: Address,
1100
+ asset: Address,
1101
+ opts: { from?: WalletClient; to?: Address } = {},
1102
+ ): Promise<Hex> {
1103
+ if (!isAddress(asset, { strict: false }) || asset.toLowerCase() === zeroAddress)
1104
+ fail('invalid', `${asset} is not an asset's address`);
1105
+ const route = this.#route(opts);
1106
+ return this.#seeded(async () => {
1107
+ const record =
1108
+ this.#ctx.state.deposits.find((d) => !d.managed && this.#via(d, address)) ??
1109
+ fail('invalid', `${address} is not a deposit address of this wallet's`);
1110
+ return refundDepositAddress(this.#ctx, record, getAddress(address), getAddress(asset), route);
1111
+ });
1112
+ }
1113
+
1114
+ /** Who sends a refund and where it goes (`rageExit`), checked before the seed is asked for. */
1115
+ #route(opts: { from?: WalletClient; to?: Address }): Route {
1116
+ const from = opts.from ?? null;
1117
+ if (from && !from.account) fail('invalid', 'the wallet client has no account');
1118
+ const to = opts.to ?? from?.account?.address ?? this.address;
1119
+ if (!isAddress(to, { strict: false }) || to.toLowerCase() === zeroAddress)
1120
+ fail('invalid', `${to} is not an address to refund to`);
1121
+ return { from, to: getAddress(to) };
1122
+ }
1123
+
944
1124
  operations(): Operation[] {
945
1125
  return this.#ctx.state.ops.map((op) => this.#operation(op));
946
1126
  }
@@ -970,6 +1150,9 @@ export class Wallet {
970
1150
  return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
971
1151
  },
972
1152
  to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
1153
+ get cancellable() {
1154
+ return cancellable(op);
1155
+ },
973
1156
  wait: async () => {
974
1157
  for (;;) {
975
1158
  if (op.status === 'done') return;
@@ -978,6 +1161,31 @@ export class Wallet {
978
1161
  if (op.status === 'pending') await new Promise((r) => setTimeout(r, 15_000));
979
1162
  }
980
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
+ },
981
1189
  };
982
1190
  }
983
1191
 
@@ -1010,7 +1218,9 @@ export class Wallet {
1010
1218
  let record = unusedBurn(ctx, token.chainId, token.tokenId);
1011
1219
  if (!record) {
1012
1220
  recording();
1013
- record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), this.#closed.signal);
1221
+ record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), {
1222
+ signal: this.#closed.signal,
1223
+ });
1014
1224
  }
1015
1225
  const next = chainState(ctx, token.chainId).burnNext;
1016
1226
  // recording may have read another device's later ones
@@ -1025,10 +1235,12 @@ export class Wallet {
1025
1235
  * burn address (approving it first if needed). The deposit is spendable once the ASP has screened it. More than the
1026
1236
  * account holds fails at once (`insufficient-funds`), before anything is asked of the wallet.
1027
1237
  *
1028
- * With `to`, an address that registered, it pays them instead, privately: into a fresh burn address of theirs,
1029
- * under the public key they registered, told to them in storage first as a note is (`planDeposit`). They
1030
- * receive it as a deposit of their own, screened like any; the burn address names no one. It shows in `activity()`
1031
- * as sent. Finding the address takes about 2^16 hashes (`burn-address`).
1238
+ * With `to`, an account that registered, by its address or its zERC20 ID, it pays them instead, privately: into a
1239
+ * fresh burn address of theirs, under the public key they registered, told to them in storage first as a note is
1240
+ * (`planDeposit`). They receive it as a deposit of their own, screened like any; the burn address names no one. It
1241
+ * shows in `activity()` as sent. Finding the address takes about 2^16 hashes (`burn-address`). Or `to` is a receive
1242
+ * code (`receiveCode`), paid into its burn address, which its owner already watches: storage is told nothing, so
1243
+ * only this device's `activity()` shows it as sent.
1032
1244
  *
1033
1245
  * With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
1034
1246
  * asset: a plain transfer into the burn address, with no wrap and no approval.
@@ -1041,7 +1253,7 @@ export class Wallet {
1041
1253
  async deposit(opts: {
1042
1254
  token: Token;
1043
1255
  amount: bigint;
1044
- to?: Address;
1256
+ to?: string;
1045
1257
  wrapped?: boolean;
1046
1258
  from?: WalletClient;
1047
1259
  onStep?: (step: DepositStep) => void;
@@ -1049,6 +1261,7 @@ export class Wallet {
1049
1261
  this.#writable();
1050
1262
  if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
1051
1263
  const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
1264
+ const to = opts.to === undefined ? null : recipientOf(opts.to, opts.token);
1052
1265
  const step = opts.onStep ?? (() => {});
1053
1266
  if (this.#busy > 0) step('queued');
1054
1267
  return this.#exclusive(async () => {
@@ -1073,14 +1286,21 @@ export class Wallet {
1073
1286
  `${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`,
1074
1287
  );
1075
1288
  }
1076
- const address = opts.to
1077
- ? await this.#burnFor(token, amount, opts.to, () => step('burn-address'))
1078
- : (await this.#burnAddress(token, null, () => step('burn-address'))).address;
1289
+ const address =
1290
+ to?.kind === 'code'
1291
+ ? to.burnAddress
1292
+ : to
1293
+ ? await this.#burnFor(token, amount, to.address, () => step('burn-address'))
1294
+ : (await this.#burnAddress(token, null, () => step('burn-address'))).address;
1079
1295
  const op = this.#newOp(
1080
1296
  'deposit',
1081
1297
  token,
1082
1298
  amount,
1083
- opts.to ? { burnAddress: address, to: opts.to } : { burnAddress: address },
1299
+ to?.kind === 'code'
1300
+ ? { burnAddress: address, code: to.code }
1301
+ : to
1302
+ ? { burnAddress: address, to: to.address }
1303
+ : { burnAddress: address },
1084
1304
  );
1085
1305
  await this.#ctx.save();
1086
1306
  try {
@@ -1153,11 +1373,7 @@ export class Wallet {
1153
1373
  async #burnFor(token: Token, amount: bigint, to: Address, searching: () => void): Promise<Hex> {
1154
1374
  const ctx = this.#ctx;
1155
1375
  const recipient = await lookupRecipient(ctx.storage, hexToBytes(to));
1156
- if (!recipient)
1157
- fail(
1158
- 'recipient-not-registered',
1159
- `${to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`,
1160
- );
1376
+ if (!recipient) fail('recipient-not-registered', `${to} never opened a zERC20 wallet: ask for a receive code`);
1161
1377
  searching();
1162
1378
  const burn = await burnAddressFor(token.chainId, bytesToHex(recipient!.publicKey), this.#closed.signal);
1163
1379
  // with the address, so that every device of this wallet finds the transfer it then sends
@@ -1179,26 +1395,35 @@ export class Wallet {
1179
1395
  /** An address that takes `token`'s underlying asset from anywhere (an exchange withdrawal, another wallet) and
1180
1396
  * deposits it privately, less `fee` (in the underlying's base units) to the deposit relayer each time: what arrives
1181
1397
  * must exceed it. A wallet opened with a `referrer` also pays it its `bps` of the rest. Watched for 7 days by the
1182
- * deposit relayer; refunds go back to this account. `onStep` hears what it waits on (`queued`, `burn-address`). */
1398
+ * deposit relayer. It names no account: its refunds go where a key of its own signs for (`rageExit`'s `to`,
1399
+ * `refundDepositAddress`). `depositAddresses()` lists it. `onStep` hears what it waits on (`queued`,
1400
+ * `burn-address`). */
1183
1401
  depositAddress(
1184
1402
  token: Token,
1185
1403
  opts: { onStep?: (step: DepositStep) => void } = {},
1186
1404
  ): Promise<{ address: Address; fee: bigint }> {
1187
1405
  const step = opts.onStep ?? (() => {});
1406
+ const ctx = this.#ctx;
1407
+ const code = (index: number) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
1408
+ // the seed is asked for only past the refund signers derived ahead
1409
+ const derived = !!refundSigner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
1188
1410
  if (this.#busy > 0) step('queued');
1189
- return this.#exclusive(async () => {
1190
- this.#writable();
1191
- const c = chainCtx(this.#ctx, token.chainId);
1411
+ return this.#seeded(async () => {
1412
+ const c = chainCtx(ctx, token.chainId);
1192
1413
  const record = await this.#burnAddress(token, null, () => step('burn-address'));
1193
- const { depositAddress, fee } = await registerDeposit(
1414
+ if (!refundSigner(ctx, code(record.burnIndex))) await this.#seed(); // calls queued before took the next ones
1415
+ const signer = refundSigner(ctx, code(record.burnIndex))!;
1416
+ const referrer = this.#referrer ?? { address: zeroAddress, bps: 0 };
1417
+ const { depositAddress, feeRecipient, fee } = await registerDeposit(
1194
1418
  c.chain,
1195
1419
  token.address,
1196
1420
  record.address,
1197
- this.address,
1198
- this.#referrer,
1421
+ { signer },
1422
+ referrer,
1199
1423
  );
1200
1424
  record.depositAddress = depositAddress;
1201
- // for the wallet's other devices, to show what comes through it: written by the next sync
1425
+ // for the wallet's other devices, to show what comes through it and what it takes, and for a flush without the
1426
+ // relayer: written by the next sync
1202
1427
  const burnAddress = record.address.toLowerCase() as Hex;
1203
1428
  this.#ctx.state.receipts.push({
1204
1429
  kind: 'deposit-address',
@@ -1206,12 +1431,48 @@ export class Wallet {
1206
1431
  tokenId: token.tokenId,
1207
1432
  burnAddress,
1208
1433
  depositAddress,
1434
+ args: { fee, feeRecipient, refundSigner: signer, referrer: referrer.address, referralBps: referrer.bps },
1209
1435
  atMs: Date.now(),
1210
1436
  unwritten: true,
1211
1437
  });
1212
1438
  await this.#ctx.save();
1213
1439
  return { address: depositAddress, fee };
1214
- });
1440
+ }, !derived);
1441
+ }
1442
+
1443
+ /** Has the deposit relayer watch deposit address `address` of this wallet's for another 7 days, at the fee it was
1444
+ * handed out with: what reached it after its 7 days is deposited then, and so is what reaches it within the next.
1445
+ * `depositAddresses()` dates it from now, on every device. */
1446
+ rewatchDepositAddress(address: Address): Promise<{ address: Address; fee: bigint }> {
1447
+ return this.#seeded(async () => {
1448
+ const receipt = this.#depositReceipt(address);
1449
+ const fee = await rewatchDeposit(chainCtx(this.#ctx, receipt.chainId).chain, getAddress(address));
1450
+ // for every device of the wallet, written by the next sync: storage dates it anew
1451
+ receipt.atMs = Date.now();
1452
+ receipt.unwritten = true;
1453
+ await this.#ctx.save();
1454
+ return { address: getAddress(address), fee };
1455
+ }, false);
1456
+ }
1457
+
1458
+ /** Deposits what deposit address `address` of this wallet's holds, as the deposit relayer would, for when it does
1459
+ * not: down, or no longer watching the address. Its receipt says all the address was registered with, wherever its
1460
+ * `fee` is known (`depositAddresses()`), so no relayer is asked. `from`, another wallet of the user's, sends the
1461
+ * flush and pays its gas, and shows on chain as the depositor: never the account's own, which the address does not
1462
+ * name. What it holds must exceed the fee (`insufficient-funds`). It shows in `activity()` as any deposit through
1463
+ * the address. */
1464
+ flushDepositAddress(address: Address, opts: { from: WalletClient }): Promise<Hex> {
1465
+ if (!opts.from.account) fail('invalid', 'the wallet client has no account');
1466
+ return this.#seeded(() => flushDepositAddress(this.#ctx, this.#depositReceipt(address), opts.from), false);
1467
+ }
1468
+
1469
+ /** The receipt of deposit address `address` of this wallet's. */
1470
+ #depositReceipt(address: Address): DepositAddressReceipt {
1471
+ return (
1472
+ this.#ctx.state.receipts.find(
1473
+ (r): r is DepositAddressReceipt => r.kind === 'deposit-address' && isAddressEqual(r.depositAddress, address),
1474
+ ) ?? fail('invalid', `${address} is not a deposit address of this wallet's`)
1475
+ );
1215
1476
  }
1216
1477
 
1217
1478
  /** A receive code for `token`, to hand to one payer: it pays any amount, as often as it likes, publicly by a transfer
@@ -1257,18 +1518,29 @@ export class Wallet {
1257
1518
  if (opts.kind === 'consolidate') {
1258
1519
  const chosen = opts.coins && this.#chosen(token, opts.coins);
1259
1520
  if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
1260
- 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(
1261
1526
  'funds-settling',
1262
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,
1263
1529
  );
1264
1530
  }
1265
1531
  const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
1266
1532
  // a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
1267
1533
  const cut = steps === 0n ? 0n : (await grossed(this.#ctx, token.chainId, token.tokenId, relayer)) - relayer;
1268
- return { relayer: relayer * steps, protocol: cut * steps, fee: (relayer + cut) * steps, steps: Number(steps) };
1534
+ return {
1535
+ relayer: relayer * steps,
1536
+ protocol: cut * steps,
1537
+ protocolBps: bpsOf(cut, relayer + cut),
1538
+ fee: (relayer + cut) * steps,
1539
+ steps: Number(steps),
1540
+ };
1269
1541
  }
1270
1542
  // as the payment's plan reckons it, so that `fee` is a `maxFee` it passes
1271
- const { protocol } = await withdrawalOf(
1543
+ const { withdrawn, protocol } = await withdrawalOf(
1272
1544
  this.#ctx,
1273
1545
  token.chainId,
1274
1546
  token.tokenId,
@@ -1279,13 +1551,27 @@ export class Wallet {
1279
1551
  const onTheWay =
1280
1552
  this.#ctx.state.ops.some((o) => o.status === 'pending') ||
1281
1553
  settling(this.#ctx, token.chainId, token.tokenId, size);
1282
- payWith(all, amount + relayer + protocol, relayer, onTheWay);
1283
- return { relayer, protocol, fee: relayer + protocol, steps: 1 };
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
+ }
1561
+ return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
1562
+ }
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')));
1284
1570
  }
1285
1571
 
1286
1572
  /** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
1287
1573
  * the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
1288
- async maxAmount(opts: { kind: 'send' | 'withdraw'; token: Token }): Promise<bigint> {
1574
+ async maxAmount(opts: { kind: 'send' | 'withdraw'; token: Token }): Promise<MaxAmount> {
1289
1575
  const { token } = opts;
1290
1576
  const c = chainCtx(this.#ctx, token.chainId);
1291
1577
  const relayer =
@@ -1314,9 +1600,16 @@ export class Wallet {
1314
1600
  return merge && merge.map((m) => `${token.chainId}:${m.index}`);
1315
1601
  }
1316
1602
 
1317
- /** Pays `amount` privately: to an address that has registered (any wallet that ever opened), or to a receive code
1318
- * (`receiveCode`). An address that never registered fails with `recipient-not-registered`. One JoinSplit from
1319
- * what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
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
+
1609
+ /** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
1610
+ * zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
1611
+ * `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
1612
+ * `funds-settling` or `insufficient-funds`.
1320
1613
  * `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
1321
1614
  * relayer names its fee, so a quote the user saw is the cap. */
1322
1615
  async send(opts: { to: string; token: Token; amount: bigint; maxFee: bigint }): Promise<Operation> {
@@ -1324,22 +1617,14 @@ export class Wallet {
1324
1617
  if (amount <= 0n) fail('invalid', 'the amount must be positive');
1325
1618
  // the JoinSplit is proved by a later sync, which holds the seed until the payment is done
1326
1619
  return this.#seeded(async () => {
1620
+ const r = recipientOf(opts.to, token);
1327
1621
  let to: Destination;
1328
- if (isAddress(opts.to)) {
1329
- const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
1622
+ if (r.kind === 'address') {
1623
+ const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(r.address));
1330
1624
  if (!recipient)
1331
- fail(
1332
- 'recipient-not-registered',
1333
- `${opts.to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`,
1334
- );
1625
+ fail('recipient-not-registered', `${opts.to.trim()} never opened a zERC20 wallet: ask for a receive code`);
1335
1626
  to = { kind: 'address', recipient: recipient! };
1336
- } else {
1337
- const code = decodeCode(opts.to);
1338
- if (!code) return fail('invalid', 'not an address or a receive code');
1339
- if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
1340
- fail('invalid', 'the receive code is for another token');
1341
- to = { kind: 'code', code: opts.to.trim() };
1342
- }
1627
+ } else to = { kind: 'code', code: r.code };
1343
1628
  return this.#newPayment('send', token, amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
1344
1629
  });
1345
1630
  }
@@ -1412,6 +1697,21 @@ export class Wallet {
1412
1697
  }
1413
1698
  }
1414
1699
 
1700
+ /** Whom `to` names, as `send` and `deposit` take it: an account, by its address or its zERC20 ID, or a receive code of
1701
+ * `token`'s. */
1702
+ function recipientOf(
1703
+ to: string,
1704
+ token: Token,
1705
+ ): { kind: 'address'; address: Address } | { kind: 'code'; code: string; burnAddress: Hex } {
1706
+ const address = isAddress(to) ? to : idAccount(to);
1707
+ if (address) return { kind: 'address', address };
1708
+ const code = decodeCode(to);
1709
+ if (!code) return fail('invalid', 'not an address, a zERC20 ID or a receive code');
1710
+ if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
1711
+ fail('invalid', 'the receive code is for another token');
1712
+ return { kind: 'code', code: to.trim(), burnAddress: bytesToHex(code.burnAddress) };
1713
+ }
1714
+
1415
1715
  /** Each chain of `network`, with its servers. */
1416
1716
  const chainsOf = (network: Network): ChainCtx[] =>
1417
1717
  network.chains.map((config) => ({
@@ -1421,22 +1721,50 @@ const chainsOf = (network: Network): ChainCtx[] =>
1421
1721
  asp: new Asp(config.asp),
1422
1722
  }));
1423
1723
 
1424
- /** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
1425
- * other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
1426
- * Nothing where there are no Web Locks. */
1427
- 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();
1428
1740
  const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
1429
- 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'));
1430
1744
  let release = () => {};
1431
1745
  const held = new Promise<void>((r) => (release = r));
1432
- const got = await new Promise<boolean>((resolve) => {
1433
- void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1434
- resolve(lock !== null);
1435
- return lock ? held : undefined;
1436
- });
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)));
1437
1756
  });
1438
1757
  if (!got) fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
1439
- 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 };
1440
1768
  }
1441
1769
 
1442
1770
  /** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or