@zerc20/wallet-sdk 0.3.0 → 0.5.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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/accountId.d.ts +5 -0
  3. package/dist/accountId.js +19 -0
  4. package/dist/accountId.js.map +1 -0
  5. package/dist/chain.d.ts +85 -9
  6. package/dist/chain.js +60 -33
  7. package/dist/chain.js.map +1 -1
  8. package/dist/context.d.ts +14 -6
  9. package/dist/context.js +17 -3
  10. package/dist/context.js.map +1 -1
  11. package/dist/core.d.ts +35 -5
  12. package/dist/core.js +95 -23
  13. package/dist/core.js.map +1 -1
  14. package/dist/errors.d.ts +2 -0
  15. package/dist/errors.js.map +1 -1
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.js +6 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/pos.d.ts +7 -1
  20. package/dist/pos.js +213 -55
  21. package/dist/pos.js.map +1 -1
  22. package/dist/rageExit.d.ts +27 -4
  23. package/dist/rageExit.js +160 -49
  24. package/dist/rageExit.js.map +1 -1
  25. package/dist/seed.d.ts +4 -0
  26. package/dist/seed.js +13 -1
  27. package/dist/seed.js.map +1 -1
  28. package/dist/servers.d.ts +2 -0
  29. package/dist/servers.js +1 -0
  30. package/dist/servers.js.map +1 -1
  31. package/dist/services.d.ts +11 -4
  32. package/dist/services.js +26 -7
  33. package/dist/services.js.map +1 -1
  34. package/dist/spend.d.ts +30 -9
  35. package/dist/spend.js +46 -17
  36. package/dist/spend.js.map +1 -1
  37. package/dist/state.d.ts +21 -0
  38. package/dist/state.js.map +1 -1
  39. package/dist/sync.d.ts +10 -5
  40. package/dist/sync.js +53 -19
  41. package/dist/sync.js.map +1 -1
  42. package/dist/viewKey.d.ts +14 -0
  43. package/dist/viewKey.js +45 -0
  44. package/dist/viewKey.js.map +1 -0
  45. package/dist/wallet.d.ts +109 -16
  46. package/dist/wallet.js +291 -85
  47. package/dist/wallet.js.map +1 -1
  48. package/package.json +3 -2
  49. package/src/accountId.ts +22 -0
  50. package/src/chain.ts +95 -49
  51. package/src/context.ts +35 -8
  52. package/src/core.ts +109 -24
  53. package/src/errors.ts +2 -0
  54. package/src/index.ts +18 -2
  55. package/src/pos.ts +240 -61
  56. package/src/rageExit.ts +237 -61
  57. package/src/seed.ts +24 -1
  58. package/src/servers.ts +4 -1
  59. package/src/services.ts +28 -15
  60. package/src/spend.ts +58 -23
  61. package/src/state.ts +25 -1
  62. package/src/sync.ts +57 -20
  63. package/src/viewKey.ts +49 -0
  64. package/src/wallet.ts +387 -106
  65. package/wasm/zerc20_wallet_core.d.ts +8 -1
  66. package/wasm/zerc20_wallet_core.js +31 -1
  67. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
  68. package/wasm/zerc20_wallet_core_bg.wasm.d.ts +1 -0
package/dist/wallet.js CHANGED
@@ -1,17 +1,20 @@
1
1
  // The wallet an app works with. Amounts are bigints in a token's base units.
2
- import { CanisterClient, codeKey, commit, decodeCode, encodeCode, lookupRecipient, nextPosLane, planDeposit, planPosLane, posKey, registeredViewKey, } from '@zerc20/storage-sdk';
3
- import { bytesToHex, formatUnits, hexToBytes, isAddress, numberToHex, zeroAddress, } from 'viem';
2
+ import { CanisterClient, codeKey, commit, decodeCode, encodeCode, lookupRecipient, nextPosLane, notifyKey, planDeposit, planPosLane, posKey, registeredViewKey, } from '@zerc20/storage-sdk';
3
+ import { bytesToHex, formatUnits, getAddress, hexToBytes, isAddress, isAddressEqual, numberToHex, zeroAddress, } from 'viem';
4
+ import { toAccount } from 'viem/accounts';
5
+ import { idAccount } from './accountId.js';
4
6
  import { Chain, MAX_REFERRAL_BPS, NATIVE, erc20Abi, tokenAbi } from './chain.js';
5
- import { chainCtx, chainState, owner, switched, walletOn, walletSees } from './context.js';
7
+ import { chainCtx, chainState, owner, refundSigner, switched, walletOn, walletSees, } from './context.js';
6
8
  import { burnAddressFor, core, initCore } from './core.js';
7
9
  import { WalletError, fail, guard } from './errors.js';
8
10
  import { checkNetwork } from './network.js';
9
11
  import { Asp, Indexer, PAGE } from './servers.js';
10
- import { rageExit } from './rageExit.js';
11
- import { registerDeposit, relayerFees } from './services.js';
12
- import { advanceSpend, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
12
+ import { flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.js';
13
+ import { registerDeposit, relayerFees, rewatchDeposit } from './services.js';
14
+ import { advanceSpend, bpsOf, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
13
15
  import { emptyWalletState, openState, sealState, stateKey, unspent, } from './state.js';
14
16
  import { WAIT_MS, commitmentTx, keyAll, nextBurn, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
17
+ import { decodeViewKey, encodeViewKey } from './viewKey.js';
15
18
  /** The ledger's bound outside an emergency. */
16
19
  const NO_BOUND = 2n ** 64n - 1n;
17
20
  export class Wallet {
@@ -24,6 +27,7 @@ export class Wallet {
24
27
  #release = () => { };
25
28
  #referrer;
26
29
  #unlock;
30
+ #readOnly = false;
27
31
  #closed = new AbortController();
28
32
  constructor(ctx, unlock) {
29
33
  this.#ctx = ctx;
@@ -39,6 +43,16 @@ export class Wallet {
39
43
  get address() {
40
44
  return this.#ctx.address;
41
45
  }
46
+ /** The account's view key, for `watch` to follow it with on another device: whoever holds it sees what this wallet
47
+ * shows, except the private payments to receive codes, and can spend none of it. Checksummed text (bech32m,
48
+ * `zerc20view1…`). */
49
+ viewKey() {
50
+ return encodeViewKey(this.#ctx.view, owner(this.#ctx));
51
+ }
52
+ /** Whether this wallet only watches its account (`watch`): it shows what the account holds and spends nothing. */
53
+ get readOnly() {
54
+ return this.#readOnly;
55
+ }
42
56
  /** Opens the wallet. The first time on this address, registers it for payments: one signature. */
43
57
  static async open(opts) {
44
58
  checkNetwork(opts.network);
@@ -70,12 +84,7 @@ export class Wallet {
70
84
  }
71
85
  static async #open(opts, seed, account, release) {
72
86
  const address = account.address;
73
- const chains = opts.network.chains.map((config) => ({
74
- config,
75
- chain: new Chain(config),
76
- indexer: new Indexer(config.indexer),
77
- asp: new Asp(config.asp),
78
- }));
87
+ const chains = chainsOf(opts.network);
79
88
  const code = await chains[0]?.chain.client.getCode({ address });
80
89
  // an EIP-7702 delegated account is still an EOA that signs
81
90
  if (code && code !== '0x' && !code.startsWith('0xef0100'))
@@ -90,37 +99,83 @@ export class Wallet {
90
99
  // ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
91
100
  const restoring = !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
92
101
  const view = await registeredViewKey(storage, seed, signer, hexToBytes(address), publicKey);
93
- const key = stateKey(seed);
94
- const loaded = saved?.length ? openState(key, saved) : null;
95
- // never started over in place: the first save would overwrite what it held, operations in flight included
96
- if (saved?.length && !loaded) {
97
- fail('invalid', 'the saved state does not open with this seed (another seed, a newer version, or damaged): clear it to start over, which finds what it held again');
98
- }
99
- if (loaded && loaded.address.toLowerCase() !== address.toLowerCase())
100
- fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
101
- const state = loaded ?? emptyWalletState(address);
102
- const ctx = {
102
+ const wallet = await Wallet.#begin({
103
103
  network: opts.network,
104
104
  seed,
105
105
  owners: new Map(),
106
+ refundSigners: new Map(),
106
107
  address,
107
108
  account,
108
109
  walletClient: opts.walletClient ?? null,
109
110
  storage,
110
111
  view,
111
112
  chains,
112
- state,
113
- save: () => opts.persist.save(sealState(key, state)),
114
- lastFullRefreshMs: 0,
115
- };
116
- if (!loaded)
117
- await start(ctx, restoring ? 0n : opts.birthday);
118
- const wallet = new Wallet(ctx, typeof opts.seed === 'function' ? opts.seed : undefined);
113
+ }, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday }, typeof opts.seed === 'function' ? opts.seed : undefined);
119
114
  wallet.#release = release;
120
115
  wallet.#referrer = opts.referrer;
116
+ return wallet;
117
+ }
118
+ /** Opens an account to follow by its view key (`viewKey()`): its balances, coins and history as its own wallet shows
119
+ * them, less the private payments to its receive codes, whose keys only the seed derives. It spends and writes
120
+ * nothing: a call that would fails with `read-only`. The key must be the account's current one on this network. */
121
+ static async watch(opts) {
122
+ checkNetwork(opts.network);
123
+ await initCore();
124
+ const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
125
+ const address = getAddress(bytesToHex(view.address));
126
+ const release = await lockWallet(address, 'view');
127
+ try {
128
+ const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
129
+ // what the account signed: a nullifying key that is not the account's would show spent coins as unspent
130
+ const r = await lookupRecipient(storage, view.address);
131
+ if (!r || bytesToHex(r.publicKey) !== owner.publicKey || bytesToHex(r.notifyKey) !== bytesToHex(notifyKey(view)))
132
+ fail('invalid', `not ${address}'s view key on ${opts.network.name}, or one it has replaced`);
133
+ const wallet = await Wallet.#begin({
134
+ network: opts.network,
135
+ seed: null,
136
+ owners: new Map([['', owner]]),
137
+ refundSigners: new Map(),
138
+ address,
139
+ // signs nothing: every call that would send is refused first
140
+ account: toAccount(address),
141
+ walletClient: null,
142
+ storage,
143
+ view,
144
+ chains: chainsOf(opts.network),
145
+ },
146
+ // registered, so its past is looked for
147
+ {
148
+ persist: opts.persist,
149
+ saved: await opts.persist.load(),
150
+ key: stateKey(view.seed),
151
+ from: opts.birthday ?? 0n,
152
+ });
153
+ wallet.#release = release;
154
+ wallet.#readOnly = true;
155
+ return wallet;
156
+ }
157
+ catch (e) {
158
+ release();
159
+ throw e;
160
+ }
161
+ }
162
+ /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
163
+ static async #begin(base, { persist, saved, key, from, }, unlock) {
164
+ const loaded = saved?.length ? openState(key, saved) : null;
165
+ // never started over in place: the first save would overwrite what it held, operations in flight included
166
+ if (saved?.length && !loaded) {
167
+ fail('invalid', 'the saved state does not open with this key (another seed or view key, a newer version, or damaged): clear it to start over, which finds what it held again');
168
+ }
169
+ if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
170
+ fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
171
+ const state = loaded ?? emptyWalletState(base.address);
172
+ const ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
173
+ if (!loaded)
174
+ await start(ctx, from);
175
+ const wallet = new Wallet(ctx, unlock);
121
176
  // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
122
177
  // and history work before the first sync
123
- const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
178
+ const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
124
179
  wallet.#tokens = tokens.flat();
125
180
  keyAll(ctx, wallet.#tokens);
126
181
  return wallet;
@@ -151,10 +206,16 @@ export class Wallet {
151
206
  * anything else (a browser shows a passkey prompt only in the click that asked), and queued in the step that took it,
152
207
  * so that a sync ending meanwhile does not let it go. */
153
208
  async #seeded(f, needs = true) {
209
+ this.#writable();
154
210
  if (needs && !this.#ctx.seed)
155
211
  this.#hold(await this.#unlock());
156
212
  return this.#exclusive(f);
157
213
  }
214
+ /** Refuses a call that spends or writes to storage, in a wallet that watches. */
215
+ #writable() {
216
+ if (this.#readOnly)
217
+ fail('read-only', 'this wallet watches its account by its view key: open it with its seed');
218
+ }
158
219
  /** Runs `f` after everything queued before it: one state, changed by one task at a time. */
159
220
  #exclusive(f) {
160
221
  this.#busy++;
@@ -192,8 +253,10 @@ export class Wallet {
192
253
  return new Promise((resolve, reject) => {
193
254
  this.#exclusive(async () => {
194
255
  await this.#catchUp().then(resolve, reject);
195
- // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout
196
- await topUpBurns(this.#ctx, this.#closed.signal).catch(() => { }); // tried again after the next sync
256
+ // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout;
257
+ // tried again after the next sync. A wallet that watches records none, as it writes nothing.
258
+ if (!this.#readOnly)
259
+ await topUpBurns(this.#ctx, this.#closed.signal).catch(() => { });
197
260
  this.#dropSeed();
198
261
  }).catch(reject); // closed before it ran
199
262
  });
@@ -201,10 +264,8 @@ export class Wallet {
201
264
  async #catchUp() {
202
265
  const errors = await syncAll(this.#ctx);
203
266
  errors.push(...(await this.#readRoots()));
204
- const listed = await Promise.all(this.#ctx.chains.map((c) => c.chain
205
- .tokens()
206
- .catch((e) => (errors.push(e), this.#tokens.filter((t) => t.chainId === c.config.chainId)))));
207
- this.#tokens = listed.flat();
267
+ // as syncAll read them: a chain it could not read keeps those read before
268
+ this.#tokens = this.#ctx.chains.flatMap((c) => c.chain.known);
208
269
  for (const op of this.#ctx.state.ops.filter((o) => o.status === 'pending')) {
209
270
  try {
210
271
  if (op.kind === 'send' || op.kind === 'withdraw' || op.kind === 'consolidate')
@@ -320,7 +381,9 @@ export class Wallet {
320
381
  amount: n.value,
321
382
  state,
322
383
  from: n.peer,
384
+ via: null,
323
385
  screenedBy: null,
386
+ at: f.atMs === undefined ? null : new Date(f.atMs),
324
387
  });
325
388
  }
326
389
  }
@@ -347,7 +410,9 @@ export class Wallet {
347
410
  amount: f.amount,
348
411
  state,
349
412
  from: f.sender,
413
+ via: this.#via(d, f.sender),
350
414
  screenedBy,
415
+ at: f.atMs === undefined ? null : new Date(f.atMs),
351
416
  });
352
417
  }
353
418
  }
@@ -418,24 +483,17 @@ export class Wallet {
418
483
  }
419
484
  }
420
485
  for (const d of s.deposits) {
421
- // registered on this device, or on another, which says so in storage
422
- const registered = d.depositAddress ??
423
- s.receipts.flatMap((r) => r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
424
- ? [r.depositAddress]
425
- : [])[0];
426
486
  // on chain once found; shown once its commitment is read, which names the amount
427
487
  for (const f of d.found.filter((f) => f.amount !== null)) {
428
- const forwarded = registered && f.sender?.toLowerCase() === registered.toLowerCase();
429
488
  // into a burn address a payer chose: theirs to this wallet, as a payment is
430
489
  const kind = d.managed ? 'sale' : d.payer ? 'received' : 'deposited';
431
- const via = forwarded ? { depositAddress: registered } : null;
432
490
  const entry = {
433
491
  ...at(kind, d.chainId, f, d.createdMs),
434
492
  token: this.#token(d.chainId, f.tokenId),
435
493
  amount: f.amount,
436
494
  burnAddress: d.address,
437
495
  };
438
- out.push({ ...entry, fee: null, counterparty: f.sender, via, txHash: null });
496
+ out.push({ ...entry, fee: null, counterparty: f.sender, via: this.#via(d, f.sender), txHash: null });
439
497
  }
440
498
  }
441
499
  // a deposit that paid someone (`deposit({ to })`), once its transfer is found
@@ -478,9 +536,17 @@ export class Wallet {
478
536
  const found = s.sent.some((x) => x.chainId === op.chainId &&
479
537
  x.address === op.data.burnAddress.toLowerCase() &&
480
538
  x.found.some((f) => f.amount !== null));
481
- if (!op.data.to || !op.data.txHash || op.status === 'pending' || found)
539
+ const { to, code, txHash } = op.data;
540
+ if ((!to && !code) || !txHash || op.status === 'pending' || found)
482
541
  continue;
483
- out.push({ ...base, kind: 'sent', fee: null, counterparty: op.data.to, via: null, txHash: op.data.txHash });
542
+ out.push({
543
+ ...base,
544
+ kind: 'sent',
545
+ fee: null,
546
+ counterparty: to ?? null,
547
+ via: code ? { code } : null,
548
+ txHash,
549
+ });
484
550
  continue;
485
551
  }
486
552
  // under way (in operations()), or shown by its change
@@ -498,6 +564,17 @@ export class Wallet {
498
564
  }
499
565
  return out.sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
500
566
  }
567
+ /** The deposit address `d` registered (here, or on another device, which says so in storage) if `sender` is it: what
568
+ * a deposit from `sender` came through. */
569
+ #via(d, sender) {
570
+ const registered = d.depositAddress ??
571
+ this.#ctx.state.receipts.flatMap((r) => r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
572
+ ? [r.depositAddress]
573
+ : [])[0];
574
+ return registered && sender && isAddressEqual(sender, registered)
575
+ ? { depositAddress: getAddress(registered) }
576
+ : null;
577
+ }
501
578
  /** The text of a receive code this wallet paid, by its burn address, from storage's record of paying it. */
502
579
  #paidCode(chainId, burnAddress) {
503
580
  for (const c of this.#ctx.state.storage?.outgoing.lanes ?? []) {
@@ -539,6 +616,31 @@ export class Wallet {
539
616
  state: f.spent ? 'spent' : f.asp === null ? 'screening' : f.asp,
540
617
  })));
541
618
  }
619
+ /** The deposit addresses this wallet handed out (`depositAddress`), on any of its devices, newest first: for an app to
620
+ * show each while what is sent to it is on its way, which `activity()` shows once deposited. Each is dated, and its
621
+ * fee told, by its receipt in storage, alike on every device. */
622
+ depositAddresses() {
623
+ return this.#ctx.state.receipts
624
+ .flatMap((r) => {
625
+ if (r.kind !== 'deposit-address')
626
+ return [];
627
+ const token = this.#token(r.chainId, r.tokenId);
628
+ return [{ address: getAddress(r.depositAddress), token, fee: r.args?.fee ?? null, at: new Date(r.atMs) }];
629
+ })
630
+ .sort((a, b) => b.at.getTime() - a.at.getTime());
631
+ }
632
+ /** What `address` is to this wallet on `chainId`: one of its own burn addresses (`deposit`), one it paid someone
633
+ * into (`payment`, `deposit({ to })` to an address), or neither (null), as is a receive code's that it paid. On chain
634
+ * either is a plain transfer to an address that names no one: this tells an app listing the account's public
635
+ * transfers which they were. Both are recorded before the transfer is sent. */
636
+ burnAddressOf(chainId, address) {
637
+ const s = this.#ctx.state;
638
+ const a = address.toLowerCase();
639
+ if (s.deposits.some((d) => d.chainId === chainId && d.address.toLowerCase() === a))
640
+ return { kind: 'deposit' };
641
+ const paid = s.sent.find((x) => x.chainId === chainId && x.address.toLowerCase() === a);
642
+ return paid ? { kind: 'payment', to: paid.to } : null;
643
+ }
542
644
  /** The payments to the burn addresses this wallet's points of sale handed out. Spent ones are, normally, in a
543
645
  * merged note. */
544
646
  payments() {
@@ -550,6 +652,7 @@ export class Wallet {
550
652
  * addresses. */
551
653
  posKey() {
552
654
  return this.#exclusive(async () => {
655
+ this.#writable();
553
656
  const ctx = this.#ctx;
554
657
  const tokens = await tokenIds(ctx);
555
658
  let n = 0;
@@ -561,8 +664,12 @@ export class Wallet {
561
664
  });
562
665
  }
563
666
  /** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
564
- * rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
565
- rageExit(id) {
667
+ * rejected, or when those services stop. The account's wallet sends the transactions and pays their gas, or with
668
+ * `from` another wallet of the user's does, as `deposit({ from })` pays. One through a deposit address of this
669
+ * wallet's (`CoinInfo.via`) goes to `to`, by default `from`'s account, else this account; any other goes back to its
670
+ * sender, and one through a point of sale's deposit address to the refund address its payer named. */
671
+ rageExit(id, opts = {}) {
672
+ const route = this.#route(opts);
566
673
  return this.#seeded(async () => {
567
674
  const [chain, index] = id.split(':');
568
675
  const record = this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
@@ -571,9 +678,32 @@ export class Wallet {
571
678
  const op = this.#ctx.state.ops.find((o) => `${o.chainId}` === chain && inputsOf(o).some((c) => `${c.index}` === index));
572
679
  if (op)
573
680
  fail('invalid', `deposit ${id} is an input of operation ${op.id}, which is in flight`);
574
- return rageExit(this.#ctx, record, Number(index));
681
+ return rageExit(this.#ctx, record, Number(index), route);
682
+ });
683
+ }
684
+ /** Sends what deposit address `address` of this wallet's holds of `asset` (NATIVE for the chain's own currency) to
685
+ * `to`: another token sent to it, which it cannot wrap. What it can wrap is wrapped instead, as a payment to it is.
686
+ * It deploys on its first flush, and refunds nothing before. `from` and `to` as for `rageExit`. */
687
+ refundDepositAddress(address, asset, opts = {}) {
688
+ if (!isAddress(asset, { strict: false }) || asset.toLowerCase() === zeroAddress)
689
+ fail('invalid', `${asset} is not an asset's address`);
690
+ const route = this.#route(opts);
691
+ return this.#seeded(async () => {
692
+ const record = this.#ctx.state.deposits.find((d) => !d.managed && this.#via(d, address)) ??
693
+ fail('invalid', `${address} is not a deposit address of this wallet's`);
694
+ return refundDepositAddress(this.#ctx, record, getAddress(address), getAddress(asset), route);
575
695
  });
576
696
  }
697
+ /** Who sends a refund and where it goes (`rageExit`), checked before the seed is asked for. */
698
+ #route(opts) {
699
+ const from = opts.from ?? null;
700
+ if (from && !from.account)
701
+ fail('invalid', 'the wallet client has no account');
702
+ const to = opts.to ?? from?.account?.address ?? this.address;
703
+ if (!isAddress(to, { strict: false }) || to.toLowerCase() === zeroAddress)
704
+ fail('invalid', `${to} is not an address to refund to`);
705
+ return { from, to: getAddress(to) };
706
+ }
577
707
  operations() {
578
708
  return this.#ctx.state.ops.map((op) => this.#operation(op));
579
709
  }
@@ -638,7 +768,9 @@ export class Wallet {
638
768
  let record = unusedBurn(ctx, token.chainId, token.tokenId);
639
769
  if (!record) {
640
770
  recording();
641
- record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), this.#closed.signal);
771
+ record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), {
772
+ signal: this.#closed.signal,
773
+ });
642
774
  }
643
775
  const next = chainState(ctx, token.chainId).burnNext;
644
776
  // recording may have read another device's later ones
@@ -652,10 +784,12 @@ export class Wallet {
652
784
  * burn address (approving it first if needed). The deposit is spendable once the ASP has screened it. More than the
653
785
  * account holds fails at once (`insufficient-funds`), before anything is asked of the wallet.
654
786
  *
655
- * With `to`, an address that registered, it pays them instead, privately: into a fresh burn address of theirs,
656
- * under the public key they registered, told to them in storage first as a note is (`planDeposit`). They
657
- * receive it as a deposit of their own, screened like any; the burn address names no one. It shows in `activity()`
658
- * as sent. Finding the address takes about 2^16 hashes (`burn-address`).
787
+ * With `to`, an account that registered, by its address or its zERC20 ID, it pays them instead, privately: into a
788
+ * fresh burn address of theirs, under the public key they registered, told to them in storage first as a note is
789
+ * (`planDeposit`). They receive it as a deposit of their own, screened like any; the burn address names no one. It
790
+ * shows in `activity()` as sent. Finding the address takes about 2^16 hashes (`burn-address`). Or `to` is a receive
791
+ * code (`receiveCode`), paid into its burn address, which its owner already watches: storage is told nothing, so
792
+ * only this device's `activity()` shows it as sent.
659
793
  *
660
794
  * With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
661
795
  * asset: a plain transfer into the burn address, with no wrap and no approval.
@@ -666,9 +800,11 @@ export class Wallet {
666
800
  * Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
667
801
  * `onStep` hears what it waits on meanwhile, for an app to show. */
668
802
  async deposit(opts) {
803
+ this.#writable();
669
804
  if (opts.amount <= 0n)
670
805
  fail('invalid', 'the amount must be positive');
671
806
  const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
807
+ const to = opts.to === undefined ? null : recipientOf(opts.to, opts.token);
672
808
  const step = opts.onStep ?? (() => { });
673
809
  if (this.#busy > 0)
674
810
  step('queued');
@@ -691,10 +827,16 @@ export class Wallet {
691
827
  const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
692
828
  fail('insufficient-funds', `${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`);
693
829
  }
694
- const address = opts.to
695
- ? await this.#burnFor(token, amount, opts.to, () => step('burn-address'))
696
- : (await this.#burnAddress(token, null, () => step('burn-address'))).address;
697
- const op = this.#newOp('deposit', token, amount, opts.to ? { burnAddress: address, to: opts.to } : { burnAddress: address });
830
+ const address = to?.kind === 'code'
831
+ ? to.burnAddress
832
+ : to
833
+ ? await this.#burnFor(token, amount, to.address, () => step('burn-address'))
834
+ : (await this.#burnAddress(token, null, () => step('burn-address'))).address;
835
+ const op = this.#newOp('deposit', token, amount, to?.kind === 'code'
836
+ ? { burnAddress: address, code: to.code }
837
+ : to
838
+ ? { burnAddress: address, to: to.address }
839
+ : { burnAddress: address });
698
840
  await this.#ctx.save();
699
841
  try {
700
842
  const wc = opts.from ? await switched(opts.from, token.chainId) : await walletOn(this.#ctx, token.chainId);
@@ -764,7 +906,7 @@ export class Wallet {
764
906
  const ctx = this.#ctx;
765
907
  const recipient = await lookupRecipient(ctx.storage, hexToBytes(to));
766
908
  if (!recipient)
767
- fail('recipient-not-registered', `${to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`);
909
+ fail('recipient-not-registered', `${to} never opened a zERC20 wallet: ask for a receive code`);
768
910
  searching();
769
911
  const burn = await burnAddressFor(token.chainId, bytesToHex(recipient.publicKey), this.#closed.signal);
770
912
  // with the address, so that every device of this wallet finds the transfer it then sends
@@ -783,17 +925,28 @@ export class Wallet {
783
925
  /** An address that takes `token`'s underlying asset from anywhere (an exchange withdrawal, another wallet) and
784
926
  * deposits it privately, less `fee` (in the underlying's base units) to the deposit relayer each time: what arrives
785
927
  * must exceed it. A wallet opened with a `referrer` also pays it its `bps` of the rest. Watched for 7 days by the
786
- * deposit relayer; refunds go back to this account. `onStep` hears what it waits on (`queued`, `burn-address`). */
928
+ * deposit relayer. It names no account: its refunds go where a key of its own signs for (`rageExit`'s `to`,
929
+ * `refundDepositAddress`). `depositAddresses()` lists it. `onStep` hears what it waits on (`queued`,
930
+ * `burn-address`). */
787
931
  depositAddress(token, opts = {}) {
788
932
  const step = opts.onStep ?? (() => { });
933
+ const ctx = this.#ctx;
934
+ const code = (index) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
935
+ // the seed is asked for only past the refund signers derived ahead
936
+ const derived = !!refundSigner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
789
937
  if (this.#busy > 0)
790
938
  step('queued');
791
- return this.#exclusive(async () => {
792
- const c = chainCtx(this.#ctx, token.chainId);
939
+ return this.#seeded(async () => {
940
+ const c = chainCtx(ctx, token.chainId);
793
941
  const record = await this.#burnAddress(token, null, () => step('burn-address'));
794
- const { depositAddress, fee } = await registerDeposit(c.chain, token.address, record.address, this.address, this.#referrer);
942
+ if (!refundSigner(ctx, code(record.burnIndex)))
943
+ await this.#seed(); // calls queued before took the next ones
944
+ const signer = refundSigner(ctx, code(record.burnIndex));
945
+ const referrer = this.#referrer ?? { address: zeroAddress, bps: 0 };
946
+ const { depositAddress, feeRecipient, fee } = await registerDeposit(c.chain, token.address, record.address, { signer }, referrer);
795
947
  record.depositAddress = depositAddress;
796
- // for the wallet's other devices, to show what comes through it: written by the next sync
948
+ // for the wallet's other devices, to show what comes through it and what it takes, and for a flush without the
949
+ // relayer: written by the next sync
797
950
  const burnAddress = record.address.toLowerCase();
798
951
  this.#ctx.state.receipts.push({
799
952
  kind: 'deposit-address',
@@ -801,12 +954,42 @@ export class Wallet {
801
954
  tokenId: token.tokenId,
802
955
  burnAddress,
803
956
  depositAddress,
957
+ args: { fee, feeRecipient, refundSigner: signer, referrer: referrer.address, referralBps: referrer.bps },
804
958
  atMs: Date.now(),
805
959
  unwritten: true,
806
960
  });
807
961
  await this.#ctx.save();
808
962
  return { address: depositAddress, fee };
809
- });
963
+ }, !derived);
964
+ }
965
+ /** Has the deposit relayer watch deposit address `address` of this wallet's for another 7 days, at the fee it was
966
+ * handed out with: what reached it after its 7 days is deposited then, and so is what reaches it within the next.
967
+ * `depositAddresses()` dates it from now, on every device. */
968
+ rewatchDepositAddress(address) {
969
+ return this.#seeded(async () => {
970
+ const receipt = this.#depositReceipt(address);
971
+ const fee = await rewatchDeposit(chainCtx(this.#ctx, receipt.chainId).chain, getAddress(address));
972
+ // for every device of the wallet, written by the next sync: storage dates it anew
973
+ receipt.atMs = Date.now();
974
+ receipt.unwritten = true;
975
+ await this.#ctx.save();
976
+ return { address: getAddress(address), fee };
977
+ }, false);
978
+ }
979
+ /** Deposits what deposit address `address` of this wallet's holds, as the deposit relayer would, for when it does
980
+ * not: down, or no longer watching the address. Its receipt says all the address was registered with, wherever its
981
+ * `fee` is known (`depositAddresses()`), so no relayer is asked. `from`, another wallet of the user's, sends the
982
+ * flush and pays its gas, and shows on chain as the depositor: never the account's own, which the address does not
983
+ * name. What it holds must exceed the fee (`insufficient-funds`). It shows in `activity()` as any deposit through
984
+ * the address. */
985
+ flushDepositAddress(address, opts) {
986
+ if (!opts.from.account)
987
+ fail('invalid', 'the wallet client has no account');
988
+ return this.#seeded(() => flushDepositAddress(this.#ctx, this.#depositReceipt(address), opts.from), false);
989
+ }
990
+ /** The receipt of deposit address `address` of this wallet's. */
991
+ #depositReceipt(address) {
992
+ return (this.#ctx.state.receipts.find((r) => r.kind === 'deposit-address' && isAddressEqual(r.depositAddress, address)) ?? fail('invalid', `${address} is not a deposit address of this wallet's`));
810
993
  }
811
994
  /** A receive code for `token`, to hand to one payer: it pays any amount, as often as it likes, publicly by a transfer
812
995
  * of the token to the code's burn address (a deposit, like a point of sale's payment addresses), or privately by
@@ -850,14 +1033,20 @@ export class Wallet {
850
1033
  const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
851
1034
  // a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
852
1035
  const cut = steps === 0n ? 0n : (await grossed(this.#ctx, token.chainId, token.tokenId, relayer)) - relayer;
853
- return { relayer: relayer * steps, protocol: cut * steps, fee: (relayer + cut) * steps, steps: Number(steps) };
1036
+ return {
1037
+ relayer: relayer * steps,
1038
+ protocol: cut * steps,
1039
+ protocolBps: bpsOf(cut, relayer + cut),
1040
+ fee: (relayer + cut) * steps,
1041
+ steps: Number(steps),
1042
+ };
854
1043
  }
855
1044
  // as the payment's plan reckons it, so that `fee` is a `maxFee` it passes
856
- const { protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
1045
+ const { withdrawn, protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
857
1046
  const onTheWay = this.#ctx.state.ops.some((o) => o.status === 'pending') ||
858
1047
  settling(this.#ctx, token.chainId, token.tokenId, size);
859
1048
  payWith(all, amount + relayer + protocol, relayer, onTheWay);
860
- return { relayer, protocol, fee: relayer + protocol, steps: 1 };
1049
+ return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
861
1050
  }
862
1051
  /** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
863
1052
  * the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
@@ -879,9 +1068,10 @@ export class Wallet {
879
1068
  const merge = mergeRound(coins(this.#ctx, token.chainId, token.tokenId, size), fee);
880
1069
  return merge && merge.map((m) => `${token.chainId}:${m.index}`);
881
1070
  }
882
- /** Pays `amount` privately: to an address that has registered (any wallet that ever opened), or to a receive code
883
- * (`receiveCode`). An address that never registered fails with `recipient-not-registered`. One JoinSplit from
884
- * what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
1071
+ /** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
1072
+ * zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
1073
+ * `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
1074
+ * `funds-settling` or `insufficient-funds`.
885
1075
  * `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
886
1076
  * relayer names its fee, so a quote the user saw is the cap. */
887
1077
  async send(opts) {
@@ -890,21 +1080,16 @@ export class Wallet {
890
1080
  fail('invalid', 'the amount must be positive');
891
1081
  // the JoinSplit is proved by a later sync, which holds the seed until the payment is done
892
1082
  return this.#seeded(async () => {
1083
+ const r = recipientOf(opts.to, token);
893
1084
  let to;
894
- if (isAddress(opts.to)) {
895
- const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
1085
+ if (r.kind === 'address') {
1086
+ const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(r.address));
896
1087
  if (!recipient)
897
- fail('recipient-not-registered', `${opts.to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`);
1088
+ fail('recipient-not-registered', `${opts.to.trim()} never opened a zERC20 wallet: ask for a receive code`);
898
1089
  to = { kind: 'address', recipient: recipient };
899
1090
  }
900
- else {
901
- const code = decodeCode(opts.to);
902
- if (!code)
903
- return fail('invalid', 'not an address or a receive code');
904
- if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
905
- fail('invalid', 'the receive code is for another token');
906
- to = { kind: 'code', code: opts.to.trim() };
907
- }
1091
+ else
1092
+ to = { kind: 'code', code: r.code };
908
1093
  return this.#newPayment('send', token, amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
909
1094
  });
910
1095
  }
@@ -973,16 +1158,37 @@ export class Wallet {
973
1158
  return this.#operation(op);
974
1159
  }
975
1160
  }
1161
+ /** Whom `to` names, as `send` and `deposit` take it: an account, by its address or its zERC20 ID, or a receive code of
1162
+ * `token`'s. */
1163
+ function recipientOf(to, token) {
1164
+ const address = isAddress(to) ? to : idAccount(to);
1165
+ if (address)
1166
+ return { kind: 'address', address };
1167
+ const code = decodeCode(to);
1168
+ if (!code)
1169
+ return fail('invalid', 'not an address, a zERC20 ID or a receive code');
1170
+ if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
1171
+ fail('invalid', 'the receive code is for another token');
1172
+ return { kind: 'code', code: to.trim(), burnAddress: bytesToHex(code.burnAddress) };
1173
+ }
1174
+ /** Each chain of `network`, with its servers. */
1175
+ const chainsOf = (network) => network.chains.map((config) => ({
1176
+ config,
1177
+ chain: new Chain(config),
1178
+ indexer: new Indexer(config.indexer),
1179
+ asp: new Asp(config.asp),
1180
+ }));
976
1181
  /** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
977
- * other's state and spend the same notes. Nothing where there are no Web Locks. */
978
- export async function lockWallet(address) {
1182
+ * other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
1183
+ * Nothing where there are no Web Locks. */
1184
+ export async function lockWallet(address, kind = 'wallet') {
979
1185
  const locks = globalThis.navigator?.locks;
980
1186
  if (!locks)
981
1187
  return () => { };
982
1188
  let release = () => { };
983
1189
  const held = new Promise((r) => (release = r));
984
1190
  const got = await new Promise((resolve) => {
985
- void locks.request(`zerc20-wallet:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1191
+ void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
986
1192
  resolve(lock !== null);
987
1193
  return lock ? held : undefined;
988
1194
  });