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