@zerc20/wallet-sdk 0.0.0-stage → 0.1.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 (84) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +26 -2
  3. package/dist/burnWorker.d.ts +1 -0
  4. package/dist/burnWorker.js +21 -0
  5. package/dist/burnWorker.js.map +1 -0
  6. package/dist/chain.d.ts +345 -0
  7. package/dist/chain.js +149 -0
  8. package/dist/chain.js.map +1 -0
  9. package/dist/context.d.ts +48 -0
  10. package/dist/context.js +40 -0
  11. package/dist/context.js.map +1 -0
  12. package/dist/core.d.ts +43 -0
  13. package/dist/core.js +106 -0
  14. package/dist/core.js.map +1 -0
  15. package/dist/errors.d.ts +36 -0
  16. package/dist/errors.js +26 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +19 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/network.d.ts +66 -0
  22. package/dist/network.js +55 -0
  23. package/dist/network.js.map +1 -0
  24. package/dist/pos.d.ts +93 -0
  25. package/dist/pos.js +302 -0
  26. package/dist/pos.js.map +1 -0
  27. package/dist/prove.d.ts +13 -0
  28. package/dist/prove.js +53 -0
  29. package/dist/prove.js.map +1 -0
  30. package/dist/rageExit.d.ts +5 -0
  31. package/dist/rageExit.js +146 -0
  32. package/dist/rageExit.js.map +1 -0
  33. package/dist/scan.d.ts +17 -0
  34. package/dist/scan.js +47 -0
  35. package/dist/scan.js.map +1 -0
  36. package/dist/seed.d.ts +8 -0
  37. package/dist/seed.js +35 -0
  38. package/dist/seed.js.map +1 -0
  39. package/dist/servers.d.ts +60 -0
  40. package/dist/servers.js +117 -0
  41. package/dist/servers.js.map +1 -0
  42. package/dist/services.d.ts +62 -0
  43. package/dist/services.js +128 -0
  44. package/dist/services.js.map +1 -0
  45. package/dist/spend.d.ts +151 -0
  46. package/dist/spend.js +745 -0
  47. package/dist/spend.js.map +1 -0
  48. package/dist/state.d.ts +174 -0
  49. package/dist/state.js +45 -0
  50. package/dist/state.js.map +1 -0
  51. package/dist/sync.d.ts +84 -0
  52. package/dist/sync.js +615 -0
  53. package/dist/sync.js.map +1 -0
  54. package/dist/tree.d.ts +19 -0
  55. package/dist/tree.js +63 -0
  56. package/dist/tree.js.map +1 -0
  57. package/dist/wallet.d.ts +301 -0
  58. package/dist/wallet.js +992 -0
  59. package/dist/wallet.js.map +1 -0
  60. package/package.json +60 -4
  61. package/src/burnWorker.ts +25 -0
  62. package/src/chain.ts +205 -0
  63. package/src/context.ts +84 -0
  64. package/src/core.ts +147 -0
  65. package/src/errors.ts +61 -0
  66. package/src/index.ts +41 -0
  67. package/src/network.ts +94 -0
  68. package/src/pos.ts +415 -0
  69. package/src/prove.ts +67 -0
  70. package/src/rageExit.ts +168 -0
  71. package/src/scan.ts +62 -0
  72. package/src/seed.ts +46 -0
  73. package/src/servers.ts +172 -0
  74. package/src/services.ts +184 -0
  75. package/src/snarkjs.d.ts +11 -0
  76. package/src/spend.ts +912 -0
  77. package/src/state.ts +211 -0
  78. package/src/sync.ts +698 -0
  79. package/src/tree.ts +108 -0
  80. package/src/wallet.ts +1297 -0
  81. package/wasm/zerc20_wallet_core.d.ts +176 -0
  82. package/wasm/zerc20_wallet_core.js +783 -0
  83. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
  84. package/wasm/zerc20_wallet_core_bg.wasm.d.ts +32 -0
package/src/wallet.ts ADDED
@@ -0,0 +1,1297 @@
1
+ // The wallet an app works with. Amounts are bigints in a token's base units.
2
+
3
+ import {
4
+ CanisterClient,
5
+ type Signer,
6
+ type SyncState,
7
+ codeKey,
8
+ commit,
9
+ decodeCode,
10
+ encodeCode,
11
+ lookupRecipient,
12
+ nextPosLane,
13
+ planDeposit,
14
+ planPosLane,
15
+ posKey,
16
+ registeredViewKey,
17
+ } from '@zerc20/storage-sdk';
18
+ import {
19
+ type Account,
20
+ type Address,
21
+ type Hex,
22
+ type WalletClient,
23
+ bytesToHex,
24
+ formatUnits,
25
+ hexToBytes,
26
+ isAddress,
27
+ numberToHex,
28
+ } from 'viem';
29
+ import { Chain, NATIVE, type Token, erc20Abi, tokenAbi } from './chain.js';
30
+ import { type Ctx, type SpendKey, chainCtx, chainState, spendKey, walletSees } from './context.js';
31
+ import { burnAddressFor, core, initCore } from './core.js';
32
+ import { type ErrorCode, WalletError, fail, guard } from './errors.js';
33
+ import { type Network, checkNetwork } from './network.js';
34
+ import { Asp, Indexer, type IndexerStatus, PAGE } from './servers.js';
35
+ import { rageExit } from './rageExit.js';
36
+ import { registerDeposit, relayerFees } from './services.js';
37
+ import {
38
+ type Destination,
39
+ type MergeStep,
40
+ type SpendData,
41
+ type Step,
42
+ advanceSpend,
43
+ chosenSettling,
44
+ coins,
45
+ grossed,
46
+ inputsOf,
47
+ landed,
48
+ maxPayment,
49
+ mergePool,
50
+ mergeRound,
51
+ payWith,
52
+ planStep,
53
+ roundsToMerge,
54
+ settling,
55
+ withdrawalOf,
56
+ } from './spend.js';
57
+ import {
58
+ type DepositOperation,
59
+ type OperationRecord,
60
+ type WalletState,
61
+ emptyWalletState,
62
+ openState,
63
+ sealState,
64
+ } from './state.js';
65
+ import {
66
+ WAIT_MS,
67
+ commitmentTx,
68
+ recordBurn,
69
+ storageState,
70
+ syncAll,
71
+ takeRefresh,
72
+ tokenIds,
73
+ topUpBurns,
74
+ unusedBurn,
75
+ } from './sync.js';
76
+
77
+ const DAY_MS = 86_400_000;
78
+ /** The ledger's bound outside an emergency. */
79
+ const NO_BOUND = 2n ** 64n - 1n;
80
+
81
+ export interface OpenOptions {
82
+ network: Network;
83
+ /** The user's wallet (MetaMask, WalletConnect, ...) as a viem client with its account. */
84
+ walletClient: WalletClient;
85
+ /** The 32-byte root of every key. See `seedFromSignature`. */
86
+ seed: Uint8Array;
87
+ /** Where the encrypted state lives between sessions. */
88
+ persist: { load(): Promise<Uint8Array | null | undefined>; save(bytes: Uint8Array): Promise<void> };
89
+ /** Where a wallet with no saved state starts looking: the block its seed was first used at, or roughly when. Without
90
+ * it, an address registered before is looked for from the chain's start, and a new one from now on. */
91
+ birthday?: bigint | Date;
92
+ }
93
+
94
+ /** What a deposit, or a deposit address, waits on: for an app to show while it does. */
95
+ export type DepositStep =
96
+ /** Behind another call of this wallet: a sync, or another operation. */
97
+ | 'queued'
98
+ /** Recording a fresh burn address in storage first, a proof of work then a storage write (seconds). Only when none
99
+ * was recorded ahead, as on a token's first deposit, or a second one before a sync has recorded the next. */
100
+ | 'burn-address'
101
+ /** The wallet is asked to approve the token to take the underlying asset; then the approval is mined (a block). */
102
+ | 'approve'
103
+ /** The wallet is asked to send the wrap into the burn address, or with `wrapped`, the transfer. */
104
+ | 'wrap';
105
+
106
+ /** Why an amount is not spendable yet. */
107
+ export type PendingReason =
108
+ /** Announced by the payer, and not yet read on chain. */
109
+ | 'incoming'
110
+ /** Waiting for a proved root that covers it (minutes). */
111
+ | 'awaiting-root'
112
+ /** Waiting for a proved root, which none will be until the protocol recovers: a forged root was proved, and the
113
+ * protocol is in an emergency. A deposit can be rage-exited meanwhile. */
114
+ | 'roots-halted'
115
+ /** A deposit the ASP has not decided on yet. */
116
+ | 'screening';
117
+
118
+ export interface Balance {
119
+ token: Token;
120
+ spendable: bigint;
121
+ pending: { amount: bigint; reason: PendingReason }[];
122
+ /** Deposits the ASP rejected: only `rageExit` returns them, to their sender. */
123
+ blocked: bigint;
124
+ }
125
+
126
+ export interface Activity {
127
+ /** The same across syncs and on every device of the wallet: `kind:chainId:index` of the commitment it made or found
128
+ * (a payment's or withdrawal's: its change's). One shown from this device's operation has the operation's id. */
129
+ id: string;
130
+ /** `sale`: a payment to an address one of this wallet's points of sale handed out. `merged`: a consolidation folded
131
+ * notes and deposits into one note. `returned`: a deposit rage-exited to its sender. */
132
+ kind: 'received' | 'sent' | 'deposited' | 'sale' | 'withdrawn' | 'merged' | 'returned';
133
+ token: Token;
134
+ amount: bigint;
135
+ /** What a payment, withdrawal or merge cost besides `amount`: the relayer's fee and the protocol's cut. Null where it
136
+ * paid none. */
137
+ fee: bigint | null;
138
+ /** The other side, where known: a payer's claimed address (unverified), where a payment or withdrawal went, or whom a
139
+ * returned deposit went back to. */
140
+ counterparty: Address | null;
141
+ /** What the counterparty alone does not say: the receive code a payment went to (it has no counterparty), or the
142
+ * deposit address a deposit came through (its counterparty, which forwarded what was sent to it). */
143
+ via: { code: string } | { depositAddress: Address } | null;
144
+ /** The burn address of this wallet's that a deposit or a sale reached: for a sale, its id at the point of sale. */
145
+ burnAddress: Address | null;
146
+ /** When it reached the chain: its block's time. Until that is read, when storage recorded it; for a failed attempt,
147
+ * when it started. */
148
+ at: Date | null;
149
+ /** Its place in the commitment tree, `chainId:index`: a deposit's (also a returned one's), a note's received or
150
+ * merged, or a payment's or withdrawal's change. */
151
+ coin: string | null;
152
+ /** Its transaction, where the wallet keeps it: a rage exit's, or one shown from this device's operation.
153
+ * `transaction` finds any. */
154
+ txHash: Hex | null;
155
+ /** `done` once on chain (in a block: a reorg may still take it back until final), `failed` for a payment or
156
+ * withdrawal that cannot land, shown only on the device that tried it. What is not on chain yet is not activity:
157
+ * an operation under way is in `operations()`, a note only announced is `incoming` in `balances()`. */
158
+ status: 'done' | 'failed';
159
+ }
160
+
161
+ /** An unspent note or deposit on chain: what payments and consolidations spend, now or once it settles. */
162
+ export interface CoinInfo {
163
+ /** Its place in the commitment tree, `chainId:index`. A deposit's is the id `rageExit` and `consolidate` take. */
164
+ id: string;
165
+ kind: 'note' | 'deposit';
166
+ token: Token;
167
+ amount: bigint;
168
+ /** Spendable, why not yet, `spending` (an input of an operation under way), or `blocked` (a deposit the ASP
169
+ * rejected: only `rageExit` returns it). */
170
+ state: 'spendable' | Exclude<PendingReason, 'incoming'> | 'spending' | 'blocked';
171
+ /** A note's payer (as claimed, unverified) or a deposit's sender, where known. */
172
+ from: Address | null;
173
+ /** For a deposit in `screening`, when the ASP should decide it: an estimate, null until there is one. */
174
+ screenedBy: Date | null;
175
+ }
176
+
177
+ export interface DepositInfo {
178
+ id: string;
179
+ /** The burn address it was sent to. */
180
+ address: Address;
181
+ /** Handed out by one of this wallet's points of sale (`posKey`), rather than for this wallet's own use. */
182
+ managed: boolean;
183
+ token: Token;
184
+ /** Unknown until its commitment is read: a sync after it lands. It is read again once final. */
185
+ amount: bigint | null;
186
+ sender: Address | null;
187
+ state: 'screening' | 'approved' | 'rejected' | 'spent';
188
+ }
189
+
190
+ /** A send's or withdrawal's recipient gets all of its amount, and the private balance pays `fee` on top. */
191
+ export interface FeeQuote {
192
+ /** To the relayer. */
193
+ relayer: bigint;
194
+ /** The protocol's cut of the public withdrawal: a withdrawal's, or the one that pays the relayer. */
195
+ protocol: bigint;
196
+ /** `relayer + protocol`: the `maxFee` this quote passes. */
197
+ fee: bigint;
198
+ /** Transactions needed: one JoinSplit for a send or a withdrawal, a consolidation's merges (each pays the relayer's
199
+ * fee again). */
200
+ steps: number;
201
+ }
202
+
203
+ export type OperationKind = OperationRecord['kind'];
204
+ export type OperationStatus = OperationRecord['status'];
205
+ export type CoinState = CoinInfo['state'];
206
+
207
+ export interface Operation {
208
+ readonly id: string;
209
+ readonly kind: OperationKind;
210
+ /** The chain it is on: its token's. */
211
+ readonly chainId: bigint;
212
+ readonly status: OperationStatus;
213
+ readonly error: string | undefined;
214
+ /** Why it failed, the code `wait()` throws. */
215
+ readonly errorCode: ErrorCode | undefined;
216
+ /** While pending, why its next step has kept failing since `since`: an error trying again may get past (a service
217
+ * down, a server behind the chain, or one of the wallet's own it cannot tell apart). It is tried again every sync;
218
+ * null once a try goes through or only waits. */
219
+ readonly retrying: { message: string; code: ErrorCode | undefined; since: Date } | null;
220
+ /** The transactions it sent so far: a deposit's, a payment's one, a consolidation's one per round. */
221
+ readonly txHashes: Hex[];
222
+ /** How far a send, withdrawal or consolidation round has come, for an app to show; null for a deposit, or between
223
+ * rounds. It moves during a sync: read it while one runs. */
224
+ readonly stage: Step['stage'] | MergeStep['stage'] | null;
225
+ /** Whom a deposit pays, when it goes into their burn address (`deposit({ to })`); null otherwise. */
226
+ readonly to: Address | null;
227
+ /** Syncs until the operation is done; throws if it fails. */
228
+ wait(): Promise<void>;
229
+ }
230
+
231
+ export class Wallet {
232
+ readonly #ctx: Ctx;
233
+ #tokens: Token[] = [];
234
+ #status = new Map<bigint, IndexerStatus & { halted: boolean }>();
235
+ #queue: Promise<unknown> = Promise.resolve();
236
+ /** Calls running or queued. */
237
+ #busy = 0;
238
+ #release = () => {};
239
+ #closed = new AbortController();
240
+
241
+ private constructor(ctx: Ctx) {
242
+ this.#ctx = ctx;
243
+ }
244
+
245
+ /** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
246
+ * queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
247
+ * ends, so that two never write its state at once. */
248
+ close(): Promise<void> {
249
+ this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
250
+ return this.#queue.then(() => this.#release());
251
+ }
252
+
253
+ get address(): Address {
254
+ return this.#ctx.address;
255
+ }
256
+
257
+ /** Opens the wallet. The first time on this address, registers it for payments: one signature. */
258
+ static async open(opts: OpenOptions): Promise<Wallet> {
259
+ await initCore();
260
+ checkNetwork(opts.network);
261
+ if (opts.seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
262
+ const account = opts.walletClient.account ?? fail('invalid', 'the wallet client has no account');
263
+ const release = await lockWallet(account.address);
264
+ try {
265
+ return await Wallet.#open(opts, account, release);
266
+ } catch (e) {
267
+ release();
268
+ throw e;
269
+ }
270
+ }
271
+
272
+ static async #open(opts: OpenOptions, account: Account, release: () => void): Promise<Wallet> {
273
+ const address = account.address;
274
+ const chains = opts.network.chains.map((config) => ({
275
+ config,
276
+ chain: new Chain(config),
277
+ indexer: new Indexer(config.indexer),
278
+ asp: new Asp(config.asp),
279
+ }));
280
+ const code = await chains[0]?.chain.client.getCode({ address });
281
+ // an EIP-7702 delegated account is still an EOA that signs
282
+ if (code && code !== '0x' && !code.startsWith('0xef0100'))
283
+ fail('unsupported-account', 'contract accounts cannot sign for a wallet');
284
+ const signer: Signer = async (message) =>
285
+ hexToBytes(await guard(opts.walletClient.signMessage({ account, message })));
286
+ const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
287
+ const publicKey = hexToBytes((core.spendKey(opts.seed) as SpendKey).publicKey);
288
+ const saved = await opts.persist.load();
289
+ // registered already: used before, here or on another device, so its past is looked for
290
+ // ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
291
+ const restoring =
292
+ !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
293
+ const view = await registeredViewKey(storage, opts.seed, signer, hexToBytes(address), publicKey);
294
+ const loaded = saved?.length ? openState(opts.seed, saved) : null;
295
+ // never started over in place: the first save would overwrite what it held, operations in flight included
296
+ if (saved?.length && !loaded) {
297
+ fail(
298
+ 'invalid',
299
+ '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',
300
+ );
301
+ }
302
+ if (loaded && loaded.address.toLowerCase() !== address.toLowerCase())
303
+ fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
304
+ const state: WalletState = loaded ?? emptyWalletState(address);
305
+ const ctx: Ctx = {
306
+ network: opts.network,
307
+ seed: opts.seed,
308
+ address,
309
+ walletClient: opts.walletClient,
310
+ storage,
311
+ view,
312
+ chains,
313
+ state,
314
+ save: () => opts.persist.save(sealState(opts.seed, state)),
315
+ lastFullRefreshMs: 0,
316
+ };
317
+ if (!loaded) await start(ctx, restoring ? 0n : opts.birthday);
318
+ const wallet = new Wallet(ctx);
319
+ wallet.#release = release;
320
+ // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
321
+ // and history work before the first sync
322
+ const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
323
+ wallet.#tokens = tokens.flat();
324
+ return wallet;
325
+ }
326
+
327
+ /** Runs `f` after everything queued before it: one state, changed by one task at a time. */
328
+ #exclusive<T>(f: () => Promise<T>): Promise<T> {
329
+ this.#busy++;
330
+ const run = this.#queue
331
+ .then(() => {
332
+ this.#closed.signal.throwIfAborted();
333
+ return f();
334
+ })
335
+ .finally(() => this.#busy--);
336
+ this.#queue = run.catch(() => undefined);
337
+ return run;
338
+ }
339
+
340
+ /** Each chain's latest proved root, which tells what is spendable, and whether the protocol is in an emergency. A
341
+ * chain that does not answer keeps what was read before, and its error is returned. */
342
+ async #readRoots(): Promise<unknown[]> {
343
+ const errors: unknown[] = [];
344
+ await Promise.all(
345
+ this.#ctx.chains.map(async (c) => {
346
+ try {
347
+ const [status, bound] = await Promise.all([c.indexer.status(), c.chain.bound()]);
348
+ // in an emergency the ledger accepts no root above its bound
349
+ const latestRoot = status.latestRoot && BigInt(status.latestRoot.size) <= bound ? status.latestRoot : null;
350
+ const halted = status.mismatch !== null || bound !== NO_BOUND;
351
+ this.#status.set(c.config.chainId, { ...status, latestRoot, halted });
352
+ } catch (e) {
353
+ errors.push(e);
354
+ }
355
+ }),
356
+ );
357
+ return errors;
358
+ }
359
+
360
+ /** Catches up: new notes, deposits, spends and roots, then advances every operation in flight. A chain or server that
361
+ * fails holds back only what needs it, and the first error is thrown once the rest is done. Settles before the burn
362
+ * addresses are topped up, which can take a phone tens of seconds. */
363
+ sync(): Promise<void> {
364
+ return new Promise((resolve, reject) => {
365
+ this.#exclusive(async () => {
366
+ await this.#catchUp().then(resolve, reject);
367
+ // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout
368
+ await topUpBurns(this.#ctx, this.#closed.signal).catch(() => {}); // tried again after the next sync
369
+ }).catch(reject); // closed before it ran
370
+ });
371
+ }
372
+
373
+ async #catchUp(): Promise<void> {
374
+ const errors = await syncAll(this.#ctx);
375
+ errors.push(...(await this.#readRoots()));
376
+ const listed = await Promise.all(
377
+ this.#ctx.chains.map((c) =>
378
+ c.chain
379
+ .tokens()
380
+ .catch((e: unknown) => (errors.push(e), this.#tokens.filter((t) => t.chainId === c.config.chainId))),
381
+ ),
382
+ );
383
+ this.#tokens = listed.flat();
384
+ for (const op of this.#ctx.state.ops.filter((o) => o.status === 'pending')) {
385
+ try {
386
+ if (op.kind === 'send' || op.kind === 'withdraw' || op.kind === 'consolidate')
387
+ await advanceSpend(this.#ctx, op);
388
+ if (op.kind === 'deposit') await settleDeposit(this.#ctx, op);
389
+ delete op.retrying;
390
+ } catch (e) {
391
+ // only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
392
+ // error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
393
+ // kept on the operation, so that one that never gets past it does not look merely slow.
394
+ if (!(e instanceof WalletError) || e.code === 'unavailable' || e.code === 'indexer-mismatch') {
395
+ const message =
396
+ e instanceof WalletError ? e.message : e instanceof Error ? `${e.name}: ${e.message}` : String(e);
397
+ op.retrying = {
398
+ message,
399
+ code: e instanceof WalletError ? e.code : undefined,
400
+ sinceMs: op.retrying?.sinceMs ?? Date.now(),
401
+ };
402
+ continue;
403
+ }
404
+ delete op.retrying;
405
+ op.status = 'failed';
406
+ op.error = e.message;
407
+ op.errorCode = e.code;
408
+ await this.#ctx.save();
409
+ }
410
+ }
411
+ if (errors.length > 0) throw errors[0];
412
+ }
413
+
414
+ /** Syncs on its own: every 15 s while something is under way, every 5 min otherwise. Returns a stop function. */
415
+ autoSync(onChange?: () => void, onError?: (e: unknown) => void): () => void {
416
+ let stopped = false;
417
+ let timer: ReturnType<typeof setTimeout> | undefined;
418
+ const tick = async () => {
419
+ try {
420
+ await this.sync();
421
+ onChange?.();
422
+ } catch (e) {
423
+ onError?.(e);
424
+ }
425
+ if (stopped || this.#closed.signal.aborted) return;
426
+ const busy = this.#ctx.state.ops.some((o) => o.status === 'pending' && Date.now() - o.createdMs < WAIT_MS * 4);
427
+ timer = setTimeout(tick, busy ? 15_000 : 300_000);
428
+ };
429
+ void tick();
430
+ return () => {
431
+ stopped = true;
432
+ clearTimeout(timer);
433
+ };
434
+ }
435
+
436
+ /** The tokens of every chain, as of opening or the last sync. */
437
+ tokens(): Token[] {
438
+ return this.#tokens;
439
+ }
440
+
441
+ #token(chainId: bigint, tokenId: number): Token {
442
+ return (
443
+ this.#tokens.find((t) => t.chainId === chainId && t.tokenId === tokenId) ??
444
+ fail('invalid', `unknown token ${tokenId} on ${chainId}`)
445
+ );
446
+ }
447
+
448
+ balances(): Balance[] {
449
+ const coins = this.coins();
450
+ return this.#tokens.map((token) => {
451
+ const { chainId, tokenId } = token;
452
+ const b: Balance = { token, spendable: 0n, pending: [], blocked: 0n };
453
+ // a merge that never landed (another device spent an input first) stops counting after a day; a zero-value
454
+ // change, there to carry its payment's receipt, never counts. A note is a coin once its commitment is read, so
455
+ // until then it is incoming, as a payment's change is between its relay and the next sync
456
+ for (const n of this.#ctx.state.notes.filter(
457
+ (n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n,
458
+ )) {
459
+ const stale =
460
+ n.receipt?.kind === 'merged' && n.atNs !== null && Date.now() - Number(n.atNs / 1_000_000n) > DAY_MS;
461
+ if (!n.found.some((f) => f.confirmed) && !stale) b.pending.push({ amount: n.value, reason: 'incoming' });
462
+ }
463
+ for (const c of coins.filter((c) => c.token === token)) {
464
+ if (c.state === 'spendable') b.spendable += c.amount;
465
+ else if (c.state === 'blocked') b.blocked += c.amount;
466
+ else if (c.state !== 'spending') b.pending.push({ amount: c.amount, reason: c.state });
467
+ }
468
+ return b;
469
+ });
470
+ }
471
+
472
+ /** Every unspent note and deposit on chain, and where each stands, as of opening or the last sync. A note announced
473
+ * but not on chain yet is not one: `balances()` counts it as incoming. */
474
+ coins(): CoinInfo[] {
475
+ const size = (chainId: bigint) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
476
+ const unproved = (chainId: bigint) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
477
+ const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
478
+ // spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it (a reorg that drops
479
+ // it brings them back as spending, and the operation relays it again)
480
+ const landed = new Set(
481
+ this.#ctx.state.ops.flatMap((o) =>
482
+ o.kind !== 'deposit' && o.data.step?.stage === 'relayed'
483
+ ? inputsOf(o).map((c) => `${o.chainId}:${c.index}`)
484
+ : [],
485
+ ),
486
+ );
487
+ const out: CoinInfo[] = [];
488
+ // a zero-value change carries its payment's receipt, and is no coin
489
+ for (const n of this.#ctx.state.notes.filter((n) => n.value > 0n)) {
490
+ for (const f of n.found.filter((f) => !f.spent)) {
491
+ const id = `${n.chainId}:${f.index}`;
492
+ // a 4-byte tag alone matches someone else's now and then: a coin once its commitment is read
493
+ if (!f.confirmed || landed.has(id)) continue;
494
+ const state = spending.has(id) ? 'spending' : f.index >= size(n.chainId) ? unproved(n.chainId) : 'spendable';
495
+ out.push({
496
+ id,
497
+ kind: 'note',
498
+ token: this.#token(n.chainId, n.tokenId),
499
+ amount: n.value,
500
+ state,
501
+ from: n.peer,
502
+ screenedBy: null,
503
+ });
504
+ }
505
+ }
506
+ for (const d of this.#ctx.state.deposits) {
507
+ // a coin once its commitment is read, which names the amount
508
+ for (const f of d.found.filter((f) => !f.spent && f.amount !== null)) {
509
+ const id = `${d.chainId}:${f.index}`;
510
+ if (landed.has(id)) continue;
511
+ const state =
512
+ f.asp === 'rejected'
513
+ ? 'blocked'
514
+ : spending.has(id)
515
+ ? 'spending'
516
+ : f.asp === null
517
+ ? 'screening'
518
+ : f.index >= size(d.chainId)
519
+ ? unproved(d.chainId)
520
+ : 'spendable';
521
+ const screenedBy = state === 'screening' && f.decidedByMs !== undefined ? new Date(f.decidedByMs) : null;
522
+ out.push({
523
+ id,
524
+ kind: 'deposit',
525
+ token: this.#token(d.chainId, f.tokenId),
526
+ amount: f.amount!,
527
+ state,
528
+ from: f.sender,
529
+ screenedBy,
530
+ });
531
+ }
532
+ }
533
+ return out;
534
+ }
535
+
536
+ /** Everything this wallet received, sent, deposited, sold, withdrew, merged and returned, newest first: the same on every
537
+ * device of the wallet, read from storage and the chain. This device's operations add only what no other device can
538
+ * know: a failed attempt, and a payment in a block whose change is not found yet. */
539
+ activity(): Activity[] {
540
+ const s = this.#ctx.state;
541
+ const out: Activity[] = [];
542
+ const ms = (t: number | bigint | null | undefined) => (t === null || t === undefined ? null : new Date(Number(t)));
543
+ /** One on chain at `coin`, dated by its block, or else `fallbackMs`. */
544
+ const at = (
545
+ kind: Activity['kind'],
546
+ chainId: bigint,
547
+ f: { index: number; atMs?: number },
548
+ fallbackMs: number | null,
549
+ ) => ({
550
+ id: `${kind}:${chainId}:${f.index}`,
551
+ kind,
552
+ coin: `${chainId}:${f.index}`,
553
+ at: ms(f.atMs ?? fallbackMs),
554
+ burnAddress: null as Address | null,
555
+ status: 'done' as const,
556
+ });
557
+ // payments whose change shows them, by the change's salt
558
+ const shown = new Set<Hex>();
559
+ for (const n of s.notes) {
560
+ // on chain, and its commitment read: a 4-byte tag alone matches someone else's now and then
561
+ const f = n.found.find((f) => f.confirmed);
562
+ if (!f) continue;
563
+ const token = this.#token(n.chainId, n.tokenId);
564
+ const stored = n.atNs === null ? null : Number(n.atNs / 1_000_000n);
565
+ const r = n.receipt;
566
+ if (n.source === 'received') {
567
+ out.push({
568
+ ...at('received', n.chainId, f, stored),
569
+ token,
570
+ amount: n.value,
571
+ fee: null,
572
+ counterparty: n.peer,
573
+ via: null,
574
+ txHash: null,
575
+ });
576
+ } else if (r?.kind === 'merged') {
577
+ out.push({
578
+ ...at('merged', n.chainId, f, stored),
579
+ token,
580
+ amount: n.value,
581
+ fee: r.fee,
582
+ counterparty: null,
583
+ via: null,
584
+ txHash: null,
585
+ });
586
+ } else if (r) {
587
+ shown.add(n.salt);
588
+ const kind = r.kind === 'withdrawn' || r.kind === 'unwrapped' ? 'withdrawn' : 'sent';
589
+ const code = r.kind === 'sent-to-code' ? this.#paidCode(n.chainId, r.counterparty) : null;
590
+ const counterparty = r.kind === 'sent-to-code' ? null : r.counterparty;
591
+ out.push({
592
+ ...at(kind, n.chainId, f, stored),
593
+ token,
594
+ amount: r.amount,
595
+ fee: r.fee,
596
+ counterparty,
597
+ via: code ? { code } : null,
598
+ txHash: null,
599
+ });
600
+ }
601
+ }
602
+ for (const d of s.deposits) {
603
+ // registered on this device, or on another, which says so in storage
604
+ const registered =
605
+ d.depositAddress ??
606
+ s.receipts.flatMap((r) =>
607
+ r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
608
+ ? [r.depositAddress]
609
+ : [],
610
+ )[0];
611
+ // on chain once found; shown once its commitment is read, which names the amount
612
+ for (const f of d.found.filter((f) => f.amount !== null)) {
613
+ const forwarded = registered && f.sender?.toLowerCase() === registered.toLowerCase();
614
+ // into a burn address a payer chose: theirs to this wallet, as a payment is
615
+ const kind = d.managed ? 'sale' : d.payer ? 'received' : 'deposited';
616
+ const via = forwarded ? { depositAddress: registered } : null;
617
+ const entry = {
618
+ ...at(kind, d.chainId, f, d.createdMs),
619
+ token: this.#token(d.chainId, f.tokenId),
620
+ amount: f.amount!,
621
+ burnAddress: d.address,
622
+ };
623
+ out.push({ ...entry, fee: null, counterparty: f.sender, via, txHash: null });
624
+ }
625
+ }
626
+ // a deposit that paid someone (`deposit({ to })`), once its transfer is found
627
+ for (const x of s.sent) {
628
+ for (const f of x.found.filter((f) => f.amount !== null)) {
629
+ out.push({
630
+ ...at('sent', x.chainId, f, x.createdMs),
631
+ token: this.#token(x.chainId, f.tokenId),
632
+ amount: f.amount!,
633
+ fee: null,
634
+ counterparty: x.to,
635
+ via: null,
636
+ txHash: null,
637
+ });
638
+ }
639
+ }
640
+ for (const r of s.receipts) {
641
+ if (r.kind !== 'returned') continue;
642
+ const entry = {
643
+ ...at('returned', r.chainId, { index: r.index }, r.atMs),
644
+ token: this.#token(r.chainId, r.tokenId),
645
+ amount: r.amount,
646
+ };
647
+ out.push({ ...entry, fee: null, counterparty: r.recipient, via: null, txHash: r.txHash });
648
+ }
649
+ for (const op of s.ops) {
650
+ const failed = op.status === 'failed';
651
+ const base = {
652
+ id: op.id,
653
+ token: this.#token(op.chainId, op.tokenId),
654
+ amount: op.amount,
655
+ at: new Date(op.createdMs),
656
+ coin: null,
657
+ burnAddress: null,
658
+ status: failed ? ('failed' as const) : ('done' as const),
659
+ };
660
+ if (op.kind === 'deposit') {
661
+ // until its transfer is found, as above
662
+ const found = s.sent.some(
663
+ (x) =>
664
+ x.chainId === op.chainId &&
665
+ x.address === op.data.burnAddress.toLowerCase() &&
666
+ x.found.some((f) => f.amount !== null),
667
+ );
668
+ if (!op.data.to || !op.data.txHash || op.status === 'pending' || found) continue;
669
+ out.push({ ...base, kind: 'sent', fee: null, counterparty: op.data.to, via: null, txHash: op.data.txHash });
670
+ continue;
671
+ }
672
+ // under way (in operations()), or shown by its change
673
+ if (op.kind === 'consolidate' || (!failed && !landed(op)) || shown.has(op.data.changes?.[0] ?? '0x')) continue;
674
+ const to = op.data.to;
675
+ out.push({
676
+ ...base,
677
+ kind: op.kind === 'send' ? 'sent' : 'withdrawn',
678
+ fee: op.data.paid,
679
+ counterparty:
680
+ to.kind === 'public' ? to.address : to.kind === 'address' ? bytesToHex(to.recipient.address) : null,
681
+ via: to.kind === 'code' ? { code: to.code } : null,
682
+ txHash: op.data.txHashes?.at(-1) ?? null,
683
+ });
684
+ }
685
+ return out.sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
686
+ }
687
+
688
+ /** The text of a receive code this wallet paid, by its burn address, from storage's record of paying it. */
689
+ #paidCode(chainId: bigint, burnAddress: Hex): string | null {
690
+ for (const c of this.#ctx.state.storage?.outgoing.lanes ?? []) {
691
+ if ('code' in c.via && c.via.code.chainId === chainId && bytesToHex(c.via.code.burnAddress) === burnAddress)
692
+ return encodeCode(c.via.code);
693
+ }
694
+ return null;
695
+ }
696
+
697
+ /** `a`'s transaction and its block's time, read now: for an app to show on asking, as a sync does not read them. A
698
+ * coin's is the ledger's log of it, in the block the indexer names. Null while the chain does not show it. */
699
+ async transaction(a: Activity): Promise<{ hash: Hex; at: Date | null } | null> {
700
+ const c =
701
+ this.#ctx.chains.find((c) => c.config.chainId === a.token.chainId) ??
702
+ fail('invalid', `unknown chain ${a.token.chainId}`);
703
+ let hash = a.txHash;
704
+ let block: bigint | null = null;
705
+ if (!hash && a.coin) {
706
+ const tx = await commitmentTx(c.chain, c.indexer, Number(a.coin.split(':')[1]));
707
+ if (!tx) return null;
708
+ ({ hash, block } = tx);
709
+ }
710
+ if (!hash) return null;
711
+ block ??= (await c.chain.client.getTransactionReceipt({ hash }).catch(() => null))?.blockNumber ?? null;
712
+ if (block === null) return { hash, at: null }; // not mined, or not yet seen by this RPC
713
+ const { timestamp } = await c.chain.client.getBlock({ blockNumber: block });
714
+ return { hash, at: new Date(Number(timestamp) * 1000) };
715
+ }
716
+
717
+ /** Every deposit found, with the id `rageExit` takes. */
718
+ deposits(): DepositInfo[] {
719
+ return this.#ctx.state.deposits.flatMap((d) =>
720
+ d.found.map((f) => ({
721
+ id: `${d.chainId}:${f.index}`,
722
+ address: d.address,
723
+ managed: d.managed === true,
724
+ token: this.#token(d.chainId, f.tokenId),
725
+ amount: f.amount,
726
+ sender: f.sender,
727
+ state: f.spent ? 'spent' : f.asp === null ? 'screening' : f.asp,
728
+ })),
729
+ );
730
+ }
731
+
732
+ /** The payments to the burn addresses this wallet's points of sale handed out. Spent ones are, normally, in a
733
+ * merged note. */
734
+ payments(): DepositInfo[] {
735
+ return this.deposits().filter((d) => d.managed);
736
+ }
737
+
738
+ /** The key of a new point of sale (`PointOfSale.connect`): the lane where it records the payment addresses it makes
739
+ * for this wallet, which this wallet, every other device of it and a restore read, and `payments()` shows what reaches
740
+ * them. The point of sale can spend none of it. One key for one point of sale: two would hand out the same
741
+ * addresses. */
742
+ posKey(): Promise<Hex> {
743
+ return this.#exclusive(async () => {
744
+ const ctx = this.#ctx;
745
+ const tokens = await tokenIds(ctx);
746
+ let n = 0;
747
+ // the number is the last plan's: after `Occupied`, another device took the first one
748
+ const planned = (s: SyncState) => planPosLane(ctx.view, s, ctx.chains[0].config.chainId, (n = nextPosLane(s)));
749
+ takeRefresh(ctx, await commit(ctx.storage, ctx.view, storageState(ctx), planned), tokens);
750
+ await ctx.save();
751
+ return bytesToHex(posKey(ctx.view.seed, n));
752
+ });
753
+ }
754
+
755
+ /** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
756
+ * rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
757
+ rageExit(id: string): Promise<Hex[]> {
758
+ return this.#exclusive(async () => {
759
+ const [chain, index] = id.split(':');
760
+ const record =
761
+ this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
762
+ fail('invalid', `no deposit ${id}`);
763
+ // an operation in flight may still land with it, and would then be taken for paid when this refund spends it
764
+ const op = this.#ctx.state.ops.find(
765
+ (o) => `${o.chainId}` === chain && inputsOf(o).some((c) => `${c.index}` === index),
766
+ );
767
+ if (op) fail('invalid', `deposit ${id} is an input of operation ${op.id}, which is in flight`);
768
+ return rageExit(this.#ctx, record, Number(index));
769
+ });
770
+ }
771
+
772
+ operations(): Operation[] {
773
+ return this.#ctx.state.ops.map((op) => this.#operation(op));
774
+ }
775
+
776
+ #operation(op: OperationRecord): Operation {
777
+ return {
778
+ id: op.id,
779
+ kind: op.kind,
780
+ chainId: op.chainId,
781
+ get status() {
782
+ return op.status;
783
+ },
784
+ get error() {
785
+ return op.error;
786
+ },
787
+ get errorCode() {
788
+ return op.errorCode;
789
+ },
790
+ get retrying() {
791
+ const r = op.retrying;
792
+ return r ? { message: r.message, code: r.code, since: new Date(r.sinceMs) } : null;
793
+ },
794
+ get txHashes() {
795
+ return op.kind === 'deposit' ? (op.data.txHash ? [op.data.txHash] : []) : (op.data.txHashes ?? []);
796
+ },
797
+ get stage() {
798
+ return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
799
+ },
800
+ to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
801
+ wait: async () => {
802
+ for (;;) {
803
+ if (op.status === 'done') return;
804
+ if (op.status === 'failed') throw new WalletError(op.errorCode ?? 'invalid', op.error ?? 'failed');
805
+ await this.sync();
806
+ if (op.status === 'pending') await new Promise((r) => setTimeout(r, 15_000));
807
+ }
808
+ },
809
+ };
810
+ }
811
+
812
+ #newOp<K extends OperationKind>(
813
+ kind: K,
814
+ token: Token,
815
+ amount: bigint,
816
+ data: (OperationRecord & { kind: K })['data'],
817
+ ): OperationRecord & { kind: K } {
818
+ // `data` is the kind's own, which TypeScript cannot follow through a generic
819
+ const op = {
820
+ id: bytesToHex(crypto.getRandomValues(new Uint8Array(8))),
821
+ kind,
822
+ chainId: token.chainId,
823
+ tokenId: token.tokenId,
824
+ amount,
825
+ createdMs: Date.now(),
826
+ status: 'pending',
827
+ data,
828
+ } as OperationRecord & { kind: K };
829
+ this.#ctx.state.ops.push(op);
830
+ return op;
831
+ }
832
+
833
+ /** A fresh burn address of `token`, recorded in storage before anything is sent to it: the one recorded ahead, or
834
+ * else a new one (about 2^16 hashes and a storage write), which `recording` is told of first. */
835
+ async #burnAddress(token: Token, depositAddress: Address | null = null, recording: () => void = () => {}) {
836
+ const ctx = this.#ctx;
837
+ // taken once recorded: a search that close() stops changes nothing
838
+ let record = unusedBurn(ctx, token.chainId, token.tokenId);
839
+ if (!record) {
840
+ recording();
841
+ record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), this.#closed.signal);
842
+ }
843
+ const next = chainState(ctx, token.chainId).burnNext;
844
+ // recording may have read another device's later ones
845
+ next[token.tokenId] = Math.max(next[token.tokenId] ?? 0, record.burnIndex + 1);
846
+ record.createdMs = Date.now();
847
+ record.depositAddress = depositAddress;
848
+ await ctx.save();
849
+ return record;
850
+ }
851
+
852
+ /** Moves `amount` of the account's public tokens into the private balance: wraps the underlying asset into a fresh
853
+ * burn address (approving it first if needed). The deposit is spendable once the ASP has screened it. More than the
854
+ * account holds fails at once (`insufficient-funds`), before anything is asked of the wallet.
855
+ *
856
+ * With `to`, an address that registered, it pays them instead, privately: into a fresh burn address of theirs,
857
+ * under the public key they registered, told to them in storage first as a note is (`planDeposit`). They
858
+ * receive it as a deposit of their own, screened like any; the burn address names no one. It shows in `activity()`
859
+ * as sent. Finding the address takes about 2^16 hashes (`burn-address`).
860
+ *
861
+ * With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
862
+ * asset: a plain transfer into the burn address, with no wrap and no approval.
863
+ *
864
+ * Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
865
+ * `onStep` hears what it waits on meanwhile, for an app to show. */
866
+ deposit(opts: {
867
+ token: Token;
868
+ amount: bigint;
869
+ to?: Address;
870
+ wrapped?: boolean;
871
+ onStep?: (step: DepositStep) => void;
872
+ }): Promise<Operation> {
873
+ const step = opts.onStep ?? (() => {});
874
+ if (this.#busy > 0) step('queued');
875
+ return this.#exclusive(async () => {
876
+ const { token, amount } = opts;
877
+ if (amount <= 0n) fail('invalid', 'the amount must be positive');
878
+ const wc = this.#ctx.walletClient;
879
+ const account = wc.account!;
880
+ const client = chainCtx(this.#ctx, token.chainId).chain.client;
881
+ const { wrapped } = opts;
882
+ const native = !wrapped && token.underlying.toLowerCase() === NATIVE.toLowerCase();
883
+ // a wrap of more would revert, and an ERC20's only after the approval it asks for first
884
+ const held = native
885
+ ? await client.getBalance({ address: account.address })
886
+ : await client.readContract({
887
+ address: wrapped ? token.address : token.underlying,
888
+ abi: erc20Abi,
889
+ functionName: 'balanceOf',
890
+ args: [account.address],
891
+ });
892
+ if (held < amount) {
893
+ const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
894
+ fail(
895
+ 'insufficient-funds',
896
+ `the account holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`,
897
+ );
898
+ }
899
+ const address = opts.to
900
+ ? await this.#burnFor(token, amount, opts.to, () => step('burn-address'))
901
+ : (await this.#burnAddress(token, null, () => step('burn-address'))).address;
902
+ const op = this.#newOp(
903
+ 'deposit',
904
+ token,
905
+ amount,
906
+ opts.to ? { burnAddress: address, to: opts.to } : { burnAddress: address },
907
+ );
908
+ await this.#ctx.save();
909
+ try {
910
+ if ((await wc.getChainId()) !== Number(token.chainId))
911
+ await guard(wc.switchChain({ id: Number(token.chainId) }));
912
+ if (!native && !wrapped) {
913
+ const allowance = await client.readContract({
914
+ address: token.underlying,
915
+ abi: erc20Abi,
916
+ functionName: 'allowance',
917
+ args: [account.address, token.address],
918
+ });
919
+ if (allowance < amount) {
920
+ step('approve');
921
+ const tx = await guard(
922
+ wc.writeContract({
923
+ account,
924
+ chain: null,
925
+ address: token.underlying,
926
+ abi: erc20Abi,
927
+ functionName: 'approve',
928
+ args: [token.address, amount],
929
+ }),
930
+ );
931
+ // mined before the wrap, still holding every other call: an approval sets the allowance, so another
932
+ // deposit's in between would overwrite this one's
933
+ await client.waitForTransactionReceipt({ hash: tx });
934
+ await walletSees(wc, tx);
935
+ }
936
+ }
937
+ step('wrap');
938
+ const tx = await guard(
939
+ wrapped
940
+ ? wc.writeContract({
941
+ account,
942
+ chain: null,
943
+ address: token.address,
944
+ abi: erc20Abi,
945
+ functionName: 'transfer',
946
+ args: [address, amount],
947
+ })
948
+ : wc.writeContract({
949
+ account,
950
+ chain: null,
951
+ address: token.address,
952
+ abi: tokenAbi,
953
+ functionName: 'wrap',
954
+ args: [amount, address],
955
+ value: native ? amount : 0n,
956
+ }),
957
+ );
958
+ op.data.txHash = tx;
959
+ // its receipt is for the next sync (settleDeposit): waiting for it here would hold every other call a block
960
+ } catch (e) {
961
+ // declined, or nothing sent for another reason: over. A sent one is settled by its receipt on a later sync.
962
+ if (!op.data.txHash) {
963
+ op.status = 'failed';
964
+ op.error = (e as Error).message;
965
+ if (e instanceof WalletError) op.errorCode = e.code;
966
+ }
967
+ await this.#ctx.save();
968
+ throw e;
969
+ }
970
+ await this.#ctx.save();
971
+ return this.#operation(op);
972
+ });
973
+ }
974
+
975
+ /** A fresh burn address of `to`'s, for a deposit of `amount` that pays them, told to them in storage: a note that only
976
+ * shows on chain cannot be found. */
977
+ async #burnFor(token: Token, amount: bigint, to: Address, searching: () => void): Promise<Hex> {
978
+ const ctx = this.#ctx;
979
+ const recipient = await lookupRecipient(ctx.storage, hexToBytes(to));
980
+ if (!recipient)
981
+ fail(
982
+ 'recipient-not-registered',
983
+ `${to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`,
984
+ );
985
+ searching();
986
+ const burn = await burnAddressFor(token.chainId, bytesToHex(recipient!.publicKey), this.#closed.signal);
987
+ // with the address, so that every device of this wallet finds the transfer it then sends
988
+ const value = hexToBytes(numberToHex(amount, { size: 32 }));
989
+ const deposit = {
990
+ chainId: token.chainId,
991
+ tokenId: token.tokenId,
992
+ value,
993
+ burnSalt: hexToBytes(burn.salt),
994
+ burnAddress: hexToBytes(burn.address),
995
+ };
996
+ const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) =>
997
+ planDeposit(ctx.view, s, recipient!, deposit),
998
+ );
999
+ takeRefresh(ctx, result, await tokenIds(ctx));
1000
+ return burn.address;
1001
+ }
1002
+
1003
+ /** An address that takes `token`'s underlying asset from anywhere (an exchange withdrawal, another wallet) and
1004
+ * deposits it privately, less `fee` (in the underlying's base units) to the deposit relayer each time: what arrives
1005
+ * must exceed it. Watched for 7 days by the deposit relayer; refunds go back to this account. `onStep` hears what it
1006
+ * waits on (`queued`, `burn-address`). */
1007
+ depositAddress(
1008
+ token: Token,
1009
+ opts: { onStep?: (step: DepositStep) => void } = {},
1010
+ ): Promise<{ address: Address; fee: bigint }> {
1011
+ const step = opts.onStep ?? (() => {});
1012
+ if (this.#busy > 0) step('queued');
1013
+ return this.#exclusive(async () => {
1014
+ const c = chainCtx(this.#ctx, token.chainId);
1015
+ const record = await this.#burnAddress(token, null, () => step('burn-address'));
1016
+ const { depositAddress, fee } = await registerDeposit(c.chain, token.address, record.address, this.address);
1017
+ record.depositAddress = depositAddress;
1018
+ // for the wallet's other devices, to show what comes through it: written by the next sync
1019
+ const burnAddress = record.address.toLowerCase() as Hex;
1020
+ this.#ctx.state.receipts.push({
1021
+ kind: 'deposit-address',
1022
+ chainId: token.chainId,
1023
+ tokenId: token.tokenId,
1024
+ burnAddress,
1025
+ depositAddress,
1026
+ atMs: Date.now(),
1027
+ unwritten: true,
1028
+ });
1029
+ await this.#ctx.save();
1030
+ return { address: depositAddress, fee };
1031
+ });
1032
+ }
1033
+
1034
+ /** A receive code for `token`, to hand to one payer: it pays any amount, as often as it likes, publicly by a transfer
1035
+ * of the token to the code's burn address (a deposit, like a point of sale's payment addresses), or privately by
1036
+ * `send`. It is one of this wallet's burn addresses, recorded in storage ahead of need, so handing it out writes
1037
+ * nothing; it names neither this address nor its public key. Anyone who gets the code can pay it, and see what its
1038
+ * other payers paid privately: give each payer its own. It is checksummed text (bech32m, `zerc201…`). */
1039
+ receiveCode(token: Token): Promise<string> {
1040
+ return this.#exclusive(async () => {
1041
+ const ctx = this.#ctx;
1042
+ const { burnIndex, address } = await this.#burnAddress(token);
1043
+ return encodeCode({
1044
+ chainId: token.chainId,
1045
+ tokenId: token.tokenId,
1046
+ burnAddress: hexToBytes(address),
1047
+ publicKey: hexToBytes(
1048
+ spendKey(ctx, { chainId: token.chainId, tokenId: token.tokenId, index: burnIndex }).publicKey,
1049
+ ),
1050
+ key: codeKey(ctx.view.seed, token.chainId, token.tokenId, burnIndex),
1051
+ });
1052
+ });
1053
+ }
1054
+
1055
+ /** What a send or withdrawal of `amount` would cost now, or a consolidation of what the wallet holds. A send or
1056
+ * withdrawal no single JoinSplit can pay now throws as it would (`needs-consolidation`, `funds-settling`,
1057
+ * `insufficient-funds`). A consolidation of chosen `coins` throws `funds-settling` until all of them are spendable. */
1058
+ async fees(opts: {
1059
+ kind: 'send' | 'withdraw' | 'consolidate';
1060
+ token: Token;
1061
+ amount?: bigint;
1062
+ coins?: string[];
1063
+ }): Promise<FeeQuote> {
1064
+ const { token } = opts;
1065
+ const amount = opts.amount ?? 0n;
1066
+ const c = chainCtx(this.#ctx, token.chainId);
1067
+ const relayer =
1068
+ (await relayerFees(c.config)).fees.get(token.tokenId)?.fee ??
1069
+ fail('unavailable', 'the relayer does not relay this token');
1070
+ const size = this.#status.get(token.chainId)?.latestRoot?.size ?? 0;
1071
+ const all = coins(this.#ctx, token.chainId, token.tokenId, size);
1072
+ if (opts.kind === 'consolidate') {
1073
+ const chosen = opts.coins && this.#chosen(token, opts.coins);
1074
+ if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
1075
+ fail(
1076
+ 'funds-settling',
1077
+ 'a chosen note or deposit is still settling: quote again once coins() shows it spendable',
1078
+ );
1079
+ }
1080
+ const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
1081
+ // a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
1082
+ const cut = steps === 0n ? 0n : (await grossed(this.#ctx, token.chainId, token.tokenId, relayer)) - relayer;
1083
+ return { relayer: relayer * steps, protocol: cut * steps, fee: (relayer + cut) * steps, steps: Number(steps) };
1084
+ }
1085
+ // as the payment's plan reckons it, so that `fee` is a `maxFee` it passes
1086
+ const { protocol } = await withdrawalOf(
1087
+ this.#ctx,
1088
+ token.chainId,
1089
+ token.tokenId,
1090
+ opts.kind === 'withdraw',
1091
+ amount,
1092
+ relayer,
1093
+ );
1094
+ const onTheWay =
1095
+ this.#ctx.state.ops.some((o) => o.status === 'pending') ||
1096
+ settling(this.#ctx, token.chainId, token.tokenId, size);
1097
+ payWith(all, amount + relayer + protocol, relayer, onTheWay);
1098
+ return { relayer, protocol, fee: relayer + protocol, steps: 1 };
1099
+ }
1100
+
1101
+ /** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
1102
+ * the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
1103
+ async maxAmount(opts: { kind: 'send' | 'withdraw'; token: Token }): Promise<bigint> {
1104
+ const { token } = opts;
1105
+ const c = chainCtx(this.#ctx, token.chainId);
1106
+ const relayer =
1107
+ (await relayerFees(c.config)).fees.get(token.tokenId)?.fee ??
1108
+ fail('unavailable', 'the relayer does not relay this token');
1109
+ const size = this.#status.get(token.chainId)?.latestRoot?.size ?? 0;
1110
+ return maxPayment(
1111
+ this.#ctx,
1112
+ token.chainId,
1113
+ token.tokenId,
1114
+ opts.kind === 'withdraw',
1115
+ coins(this.#ctx, token.chainId, token.tokenId, size),
1116
+ relayer,
1117
+ );
1118
+ }
1119
+
1120
+ /** What the next merge of a consolidation of everything would spend, as `coins()` ids: up to 100, the largest first,
1121
+ * for `consolidate({ coins })`. Null when nothing spendable is worth merging. */
1122
+ async nextMerge(token: Token): Promise<string[] | null> {
1123
+ const c = chainCtx(this.#ctx, token.chainId);
1124
+ const fee =
1125
+ (await relayerFees(c.config)).fees.get(token.tokenId)?.fee ??
1126
+ fail('unavailable', 'the relayer does not relay this token');
1127
+ const size = this.#status.get(token.chainId)?.latestRoot?.size ?? 0;
1128
+ const merge = mergeRound(coins(this.#ctx, token.chainId, token.tokenId, size), fee);
1129
+ return merge && merge.map((m) => `${token.chainId}:${m.index}`);
1130
+ }
1131
+
1132
+ /** Pays `amount` privately: to an address that has registered (any wallet that ever opened), or to a receive code
1133
+ * (`receiveCode`). An address that never registered fails with `recipient-not-registered`. One JoinSplit from
1134
+ * what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
1135
+ * `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
1136
+ * relayer names its fee, so a quote the user saw is the cap. */
1137
+ send(opts: { to: string; token: Token; amount: bigint; maxFee: bigint }): Promise<Operation> {
1138
+ return this.#exclusive(async () => {
1139
+ const { token, amount } = opts;
1140
+ if (amount <= 0n) fail('invalid', 'the amount must be positive');
1141
+ let to: Destination;
1142
+ if (isAddress(opts.to)) {
1143
+ const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
1144
+ if (!recipient)
1145
+ fail(
1146
+ 'recipient-not-registered',
1147
+ `${opts.to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`,
1148
+ );
1149
+ to = { kind: 'address', recipient: recipient! };
1150
+ } else {
1151
+ const code = decodeCode(opts.to);
1152
+ if (!code) return fail('invalid', 'not an address or a receive code');
1153
+ if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
1154
+ fail('invalid', 'the receive code is for another token');
1155
+ to = { kind: 'code', code: opts.to.trim() };
1156
+ }
1157
+ return this.#newPayment('send', token, amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
1158
+ });
1159
+ }
1160
+
1161
+ /** Merges `token`'s scattered notes and deposits into one note, without paying anyone: fewer, larger notes make
1162
+ * later payments a single JoinSplit. One Nova merge spends up to 100 of them and pays the relayer's fee once; the
1163
+ * network's merge prover proves it, seeing its witness in its enclave while it does: the openings of what it spends, but
1164
+ * no private key, so it can prove only the merge this wallet signed. More
1165
+ * take another merge, once a proved root is over the first one's note; `fees({ kind: 'consolidate' })` tells how
1166
+ * many. A lone deposit becomes a note. A payment that fails with `needs-consolidation` goes through after this.
1167
+ *
1168
+ * With `coins` (ids from `coins()`, notes and deposits), only those are merged, into one note, and nothing else is
1169
+ * touched; one still settling is waited for. `maxFee` caps the fees of every merge together,
1170
+ * `fees({ kind: 'consolidate' }).fee`. */
1171
+ consolidate(opts: { token: Token; coins?: string[]; maxFee: bigint }): Promise<Operation> {
1172
+ return this.#exclusive(async () => {
1173
+ if (!this.#ctx.network.mergeProver) fail('invalid', 'this network has no merge prover');
1174
+ const to: Destination = { kind: 'self' };
1175
+ const data: SpendData = { to, maxFee: opts.maxFee, step: null, paid: 0n };
1176
+ if (opts.coins) data.chosen = this.#chosen(opts.token, opts.coins);
1177
+ const op = this.#newOp('consolidate', opts.token, 0n, data);
1178
+ await this.#ctx.save();
1179
+ return this.#operation(op);
1180
+ });
1181
+ }
1182
+
1183
+ /** The commitment indices of coins `ids` of `token`, none blocked or already being spent. */
1184
+ #chosen(token: Token, ids: string[]): number[] {
1185
+ if (ids.length === 0) fail('invalid', 'choose at least one note or deposit');
1186
+ const coins = this.coins();
1187
+ return ids.map((id) => {
1188
+ const c = coins.find((x) => x.id === id) ?? fail('invalid', `no unspent note or deposit ${id}`);
1189
+ if (c.token.chainId !== token.chainId || c.token.tokenId !== token.tokenId)
1190
+ fail('invalid', `${id} is not ${token.symbol}`);
1191
+ if (c.state === 'blocked' || c.state === 'spending') fail('invalid', `${id} is ${c.state}`);
1192
+ return Number(id.split(':')[1]);
1193
+ });
1194
+ }
1195
+
1196
+ /** Withdraws `amount` publicly to `to`, as zERC20 or with `unwrap` the underlying asset itself, so `to` needs no gas
1197
+ * to unwrap. One JoinSplit, as `send`, and as there `to` gets all of `amount` and `maxFee` caps what is paid on top
1198
+ * (`fees({ kind: 'withdraw' }).fee`). */
1199
+ withdraw(opts: { to: Address; token: Token; amount: bigint; maxFee: bigint; unwrap?: boolean }): Promise<Operation> {
1200
+ return this.#exclusive(async () => {
1201
+ if (!isAddress(opts.to)) fail('invalid', `${String(opts.to)} is not an address`);
1202
+ if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
1203
+ const to: Destination = { kind: 'public', address: opts.to, unwrap: opts.unwrap };
1204
+ return this.#newPayment('withdraw', opts.token, opts.amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
1205
+ });
1206
+ }
1207
+
1208
+ /** A send or withdrawal with its one JoinSplit planned now: it fails here rather than later, and its inputs are
1209
+ * taken at once, so a payment right after it cannot count on them. */
1210
+ async #newPayment(kind: 'send' | 'withdraw', token: Token, amount: bigint, data: SpendData): Promise<Operation> {
1211
+ const op = this.#newOp(kind, token, amount, data);
1212
+ try {
1213
+ data.step = (await planStep(this.#ctx, op)) as Step; // a payment's plan is a step, or a throw
1214
+ } catch (e) {
1215
+ this.#ctx.state.ops.splice(this.#ctx.state.ops.indexOf(op), 1);
1216
+ throw e;
1217
+ }
1218
+ await this.#ctx.save();
1219
+ return this.#operation(op);
1220
+ }
1221
+ }
1222
+
1223
+ /** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
1224
+ * other's state and spend the same notes. Nothing where there are no Web Locks. */
1225
+ export async function lockWallet(address: Address): Promise<() => void> {
1226
+ const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
1227
+ if (!locks) return () => {};
1228
+ let release = () => {};
1229
+ const held = new Promise<void>((r) => (release = r));
1230
+ const got = await new Promise<boolean>((resolve) => {
1231
+ void locks.request(`zerc20-wallet:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1232
+ resolve(lock !== null);
1233
+ return lock ? held : undefined;
1234
+ });
1235
+ });
1236
+ if (!got) fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
1237
+ return release;
1238
+ }
1239
+
1240
+ /** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or
1241
+ * failed if it never sent one. Runs only between tasks, so no `deposit()` is still on its way to sending it. */
1242
+ async function settleDeposit(ctx: Ctx, op: DepositOperation): Promise<void> {
1243
+ const hash = op.data.txHash;
1244
+ if (hash) {
1245
+ const receipt = await chainCtx(ctx, op.chainId)
1246
+ .chain.client.getTransactionReceipt({ hash })
1247
+ .catch(() => null);
1248
+ if (!receipt) return; // not mined yet
1249
+ op.status = receipt.status === 'success' ? 'done' : 'failed';
1250
+ if (op.status === 'failed') op.error = 'the deposit transaction reverted';
1251
+ } else {
1252
+ op.status = 'failed';
1253
+ op.error = 'the deposit was never sent';
1254
+ }
1255
+ await ctx.save();
1256
+ }
1257
+
1258
+ /** A fresh state: a new wallet looks only at what comes next, a restored one from its birthday on. */
1259
+ async function start(ctx: Ctx, birthday: bigint | Date | undefined): Promise<void> {
1260
+ for (const c of ctx.chains) {
1261
+ const s = chainState(ctx, c.config.chainId);
1262
+ const status = await c.indexer.status();
1263
+ let floor = { commitments: status.commitmentsFinal, nullifiers: status.nullifiersFinal };
1264
+ if (birthday !== undefined) floor = await floorAt(c, birthday);
1265
+ s.samples.push({ atMs: 0, ...floor });
1266
+ }
1267
+ await ctx.save();
1268
+ }
1269
+
1270
+ /** The counts before `birthday` (a block, or a date found by bisecting block times), from the indexer's marks. */
1271
+ async function floorAt(
1272
+ c: { chain: Chain; indexer: Indexer },
1273
+ birthday: bigint | Date,
1274
+ ): Promise<{ commitments: number; nullifiers: number }> {
1275
+ let block = typeof birthday === 'bigint' ? birthday : 0n;
1276
+ if (birthday instanceof Date) {
1277
+ let [lo, hi] = [0n, await c.chain.client.getBlockNumber()];
1278
+ const target = BigInt(Math.floor(birthday.getTime() / 1000));
1279
+ while (lo < hi) {
1280
+ const mid = (lo + hi + 1n) / 2n;
1281
+ if ((await c.chain.client.getBlock({ blockNumber: mid })).timestamp <= target) lo = mid;
1282
+ else hi = mid - 1n;
1283
+ }
1284
+ block = lo;
1285
+ }
1286
+ // the last page of commitments that began before the birthday
1287
+ let floor = { commitments: 0, nullifiers: 0 };
1288
+ for (let p = 0; ; p++) {
1289
+ const marks = await c.indexer.page('marks', p);
1290
+ for (let k = 0; k < marks.length / 16; k++) {
1291
+ const view = new DataView(marks.buffer, marks.byteOffset + k * 16, 16);
1292
+ if (view.getBigUint64(0, false) > block) return floor;
1293
+ floor = { commitments: (p * PAGE + k) * PAGE, nullifiers: Number(view.getBigUint64(8, false)) };
1294
+ }
1295
+ if (marks.length < PAGE * 16) return floor;
1296
+ }
1297
+ }