@zerc20/wallet-sdk 0.2.0 → 0.4.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 (53) hide show
  1. package/dist/burnWorker.js +6 -6
  2. package/dist/burnWorker.js.map +1 -1
  3. package/dist/context.d.ts +25 -4
  4. package/dist/context.js +36 -6
  5. package/dist/context.js.map +1 -1
  6. package/dist/core.d.ts +32 -17
  7. package/dist/core.js +253 -70
  8. package/dist/core.js.map +1 -1
  9. package/dist/errors.d.ts +2 -0
  10. package/dist/errors.js.map +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +3 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/pos.js +2 -2
  15. package/dist/pos.js.map +1 -1
  16. package/dist/rageExit.js +3 -5
  17. package/dist/rageExit.js.map +1 -1
  18. package/dist/seed.d.ts +4 -0
  19. package/dist/seed.js +12 -1
  20. package/dist/seed.js.map +1 -1
  21. package/dist/spend.d.ts +1 -1
  22. package/dist/spend.js +5 -4
  23. package/dist/spend.js.map +1 -1
  24. package/dist/state.d.ts +14 -4
  25. package/dist/state.js +10 -7
  26. package/dist/state.js.map +1 -1
  27. package/dist/sync.d.ts +16 -3
  28. package/dist/sync.js +93 -25
  29. package/dist/sync.js.map +1 -1
  30. package/dist/viewKey.d.ts +14 -0
  31. package/dist/viewKey.js +45 -0
  32. package/dist/viewKey.js.map +1 -0
  33. package/dist/wallet.d.ts +39 -12
  34. package/dist/wallet.js +209 -85
  35. package/dist/wallet.js.map +1 -1
  36. package/package.json +5 -3
  37. package/src/burnWorker.ts +9 -10
  38. package/src/context.ts +51 -8
  39. package/src/core.ts +279 -87
  40. package/src/errors.ts +2 -0
  41. package/src/index.ts +4 -1
  42. package/src/pos.ts +2 -2
  43. package/src/rageExit.ts +3 -4
  44. package/src/seed.ts +13 -1
  45. package/src/spend.ts +5 -5
  46. package/src/state.ts +17 -7
  47. package/src/sync.ts +97 -24
  48. package/src/viewKey.ts +49 -0
  49. package/src/wallet.ts +270 -87
  50. package/wasm/zerc20_wallet_core.d.ts +20 -10
  51. package/wasm/zerc20_wallet_core.js +74 -37
  52. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
  53. package/wasm/zerc20_wallet_core_bg.wasm.d.ts +4 -3
package/src/wallet.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  encodeCode,
11
11
  lookupRecipient,
12
12
  nextPosLane,
13
+ notifyKey,
13
14
  planDeposit,
14
15
  planPosLane,
15
16
  posKey,
@@ -19,16 +20,29 @@ import {
19
20
  type Account,
20
21
  type Address,
21
22
  type Hex,
23
+ type LocalAccount,
22
24
  type WalletClient,
23
25
  bytesToHex,
24
26
  formatUnits,
27
+ getAddress,
25
28
  hexToBytes,
26
29
  isAddress,
27
30
  numberToHex,
28
31
  zeroAddress,
29
32
  } from 'viem';
33
+ import { toAccount } from 'viem/accounts';
30
34
  import { Chain, MAX_REFERRAL_BPS, NATIVE, type Referrer, type Token, erc20Abi, tokenAbi } from './chain.js';
31
- import { type Ctx, type SpendKey, chainCtx, chainState, spendKey, walletSees } from './context.js';
35
+ import {
36
+ type ChainCtx,
37
+ type Ctx,
38
+ type SpendKey,
39
+ chainCtx,
40
+ chainState,
41
+ owner,
42
+ switched,
43
+ walletOn,
44
+ walletSees,
45
+ } from './context.js';
32
46
  import { burnAddressFor, core, initCore } from './core.js';
33
47
  import { type ErrorCode, WalletError, fail, guard } from './errors.js';
34
48
  import { type Network, checkNetwork } from './network.js';
@@ -62,10 +76,14 @@ import {
62
76
  emptyWalletState,
63
77
  openState,
64
78
  sealState,
79
+ stateKey,
80
+ unspent,
65
81
  } from './state.js';
66
82
  import {
67
83
  WAIT_MS,
68
84
  commitmentTx,
85
+ keyAll,
86
+ nextBurn,
69
87
  recordBurn,
70
88
  storageState,
71
89
  syncAll,
@@ -74,17 +92,25 @@ import {
74
92
  topUpBurns,
75
93
  unusedBurn,
76
94
  } from './sync.js';
95
+ import { decodeViewKey, encodeViewKey } from './viewKey.js';
77
96
 
78
- const DAY_MS = 86_400_000;
79
97
  /** The ledger's bound outside an emergency. */
80
98
  const NO_BOUND = 2n ** 64n - 1n;
81
99
 
82
100
  export interface OpenOptions {
83
101
  network: Network;
84
- /** The user's wallet (MetaMask, WalletConnect, ...) as a viem client with its account. */
85
- walletClient: WalletClient;
86
- /** The 32-byte root of every key. See `seedFromSignature`. */
87
- seed: Uint8Array;
102
+ /** The account's key, held by the app as a viem local account (a private key's, or `accountKey`'s): the wallet sends
103
+ * its transactions through each chain's RPC. Give this or `walletClient`. */
104
+ account?: LocalAccount;
105
+ /** The user's wallet (MetaMask, WalletConnect, ...) as a viem client with its account, which the wallet switches to
106
+ * each chain it sends on. */
107
+ walletClient?: WalletClient;
108
+ /** The 32-byte root of every key (see `seedFromSignature`), held while the wallet is open. Or a function that gives
109
+ * it, a passkey's say, to hold the seed only while it is needed: asked as the wallet opens, and again to pay,
110
+ * withdraw, consolidate or exit, or for a receive code past those derived ahead, each time before the call waits on
111
+ * anything (a browser shows a passkey prompt only in the click that asked). The seed goes at the end of a sync that
112
+ * nothing waits behind, once nothing in flight signs again. */
113
+ seed: Uint8Array | (() => Promise<Uint8Array>);
88
114
  /** Where the encrypted state lives between sessions. */
89
115
  persist: { load(): Promise<Uint8Array | null | undefined>; save(bytes: Uint8Array): Promise<void> };
90
116
  /** Where a wallet with no saved state starts looking: the block its seed was first used at, or roughly when. Without
@@ -94,6 +120,11 @@ export interface OpenOptions {
94
120
  referrer?: Referrer;
95
121
  }
96
122
 
123
+ export interface WatchOptions extends Pick<OpenOptions, 'network' | 'persist' | 'birthday'> {
124
+ /** The account's view key, from its wallet's `viewKey()`. */
125
+ viewKey: string;
126
+ }
127
+
97
128
  /** What a deposit, or a deposit address, waits on: for an app to show while it does. */
98
129
  export type DepositStep =
99
130
  /** Behind another call of this wallet: a sync, or another operation. */
@@ -108,7 +139,7 @@ export type DepositStep =
108
139
 
109
140
  /** Why an amount is not spendable yet. */
110
141
  export type PendingReason =
111
- /** Announced by the payer, and not yet read on chain. */
142
+ /** A note this device's payment, withdrawal or merge under way is making, not yet read on chain. */
112
143
  | 'incoming'
113
144
  /** Waiting for a proved root that covers it (minutes). */
114
145
  | 'awaiting-root'
@@ -157,7 +188,7 @@ export interface Activity {
157
188
  txHash: Hex | null;
158
189
  /** `done` once on chain (in a block: a reorg may still take it back until final), `failed` for a payment or
159
190
  * withdrawal that cannot land, shown only on the device that tried it. What is not on chain yet is not activity:
160
- * an operation under way is in `operations()`, a note only announced is `incoming` in `balances()`. */
191
+ * an operation under way is in `operations()`, and the note it is making `incoming` in `balances()`. */
161
192
  status: 'done' | 'failed';
162
193
  }
163
194
 
@@ -240,10 +271,13 @@ export class Wallet {
240
271
  #busy = 0;
241
272
  #release = () => {};
242
273
  #referrer: Referrer | undefined;
274
+ readonly #unlock: (() => Promise<Uint8Array>) | undefined;
275
+ #readOnly = false;
243
276
  #closed = new AbortController();
244
277
 
245
- private constructor(ctx: Ctx) {
278
+ private constructor(ctx: Ctx, unlock?: () => Promise<Uint8Array>) {
246
279
  this.#ctx = ctx;
280
+ this.#unlock = unlock;
247
281
  }
248
282
 
249
283
  /** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
@@ -258,11 +292,21 @@ export class Wallet {
258
292
  return this.#ctx.address;
259
293
  }
260
294
 
295
+ /** The account's view key, for `watch` to follow it with on another device: whoever holds it sees what this wallet
296
+ * shows, except the private payments to receive codes, and can spend none of it. Checksummed text (bech32m,
297
+ * `zerc20view1…`). */
298
+ viewKey(): string {
299
+ return encodeViewKey(this.#ctx.view, owner(this.#ctx)!);
300
+ }
301
+
302
+ /** Whether this wallet only watches its account (`watch`): it shows what the account holds and spends nothing. */
303
+ get readOnly(): boolean {
304
+ return this.#readOnly;
305
+ }
306
+
261
307
  /** Opens the wallet. The first time on this address, registers it for payments: one signature. */
262
308
  static async open(opts: OpenOptions): Promise<Wallet> {
263
- await initCore();
264
309
  checkNetwork(opts.network);
265
- if (opts.seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
266
310
  // the factory refuses these, which the relayer would report as its own failure
267
311
  const r = opts.referrer;
268
312
  if (r) {
@@ -271,72 +315,179 @@ export class Wallet {
271
315
  if (!Number.isInteger(r.bps) || r.bps < 0 || r.bps > MAX_REFERRAL_BPS)
272
316
  fail('invalid', `a referrer takes 0 to ${MAX_REFERRAL_BPS} bps, not ${r.bps}`);
273
317
  }
274
- const account = opts.walletClient.account ?? fail('invalid', 'the wallet client has no account');
318
+ if (!opts.account === !opts.walletClient) fail('invalid', 'give an account or a wallet client, one of the two');
319
+ const account = opts.account ?? opts.walletClient!.account ?? fail('invalid', 'the wallet client has no account');
320
+ // asked once the options are checked, and before anything is waited on: a browser shows a passkey prompt only in
321
+ // the click that opened
322
+ const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
323
+ if (seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
324
+ await initCore();
275
325
  const release = await lockWallet(account.address);
276
326
  try {
277
- return await Wallet.#open(opts, account, release);
327
+ return await Wallet.#open(opts, seed, account, release);
278
328
  } catch (e) {
279
329
  release();
280
330
  throw e;
281
331
  }
282
332
  }
283
333
 
284
- static async #open(opts: OpenOptions, account: Account, release: () => void): Promise<Wallet> {
334
+ static async #open(opts: OpenOptions, seed: Uint8Array, account: Account, release: () => void): Promise<Wallet> {
285
335
  const address = account.address;
286
- const chains = opts.network.chains.map((config) => ({
287
- config,
288
- chain: new Chain(config),
289
- indexer: new Indexer(config.indexer),
290
- asp: new Asp(config.asp),
291
- }));
336
+ const chains = chainsOf(opts.network);
292
337
  const code = await chains[0]?.chain.client.getCode({ address });
293
338
  // an EIP-7702 delegated account is still an EOA that signs
294
339
  if (code && code !== '0x' && !code.startsWith('0xef0100'))
295
340
  fail('unsupported-account', 'contract accounts cannot sign for a wallet');
296
341
  const signer: Signer = async (message) =>
297
- hexToBytes(await guard(opts.walletClient.signMessage({ account, message })));
342
+ hexToBytes(
343
+ await guard(
344
+ account.type === 'local'
345
+ ? account.signMessage({ message })
346
+ : opts.walletClient!.signMessage({ account, message }),
347
+ ),
348
+ );
298
349
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
299
- const publicKey = hexToBytes((core.spendKey(opts.seed) as SpendKey).publicKey);
350
+ const publicKey = hexToBytes((core.spendKey(seed) as SpendKey).publicKey);
300
351
  const saved = await opts.persist.load();
301
352
  // registered already: used before, here or on another device, so its past is looked for
302
353
  // ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
303
354
  const restoring =
304
355
  !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
305
- const view = await registeredViewKey(storage, opts.seed, signer, hexToBytes(address), publicKey);
306
- const loaded = saved?.length ? openState(opts.seed, saved) : null;
356
+ const view = await registeredViewKey(storage, seed, signer, hexToBytes(address), publicKey);
357
+ const wallet = await Wallet.#begin(
358
+ {
359
+ network: opts.network,
360
+ seed,
361
+ owners: new Map(),
362
+ address,
363
+ account,
364
+ walletClient: opts.walletClient ?? null,
365
+ storage,
366
+ view,
367
+ chains,
368
+ },
369
+ { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday },
370
+ typeof opts.seed === 'function' ? opts.seed : undefined,
371
+ );
372
+ wallet.#release = release;
373
+ wallet.#referrer = opts.referrer;
374
+ return wallet;
375
+ }
376
+
377
+ /** Opens an account to follow by its view key (`viewKey()`): its balances, coins and history as its own wallet shows
378
+ * them, less the private payments to its receive codes, whose keys only the seed derives. It spends and writes
379
+ * nothing: a call that would fails with `read-only`. The key must be the account's current one on this network. */
380
+ static async watch(opts: WatchOptions): Promise<Wallet> {
381
+ checkNetwork(opts.network);
382
+ await initCore();
383
+ const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
384
+ const address = getAddress(bytesToHex(view.address));
385
+ const release = await lockWallet(address, 'view');
386
+ try {
387
+ const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
388
+ // what the account signed: a nullifying key that is not the account's would show spent coins as unspent
389
+ const r = await lookupRecipient(storage, view.address);
390
+ if (!r || bytesToHex(r.publicKey) !== owner.publicKey || bytesToHex(r.notifyKey) !== bytesToHex(notifyKey(view)))
391
+ fail('invalid', `not ${address}'s view key on ${opts.network.name}, or one it has replaced`);
392
+ const wallet = await Wallet.#begin(
393
+ {
394
+ network: opts.network,
395
+ seed: null,
396
+ owners: new Map([['', owner]]),
397
+ address,
398
+ // signs nothing: every call that would send is refused first
399
+ account: toAccount(address),
400
+ walletClient: null,
401
+ storage,
402
+ view,
403
+ chains: chainsOf(opts.network),
404
+ },
405
+ // registered, so its past is looked for
406
+ {
407
+ persist: opts.persist,
408
+ saved: await opts.persist.load(),
409
+ key: stateKey(view.seed),
410
+ from: opts.birthday ?? 0n,
411
+ },
412
+ );
413
+ wallet.#release = release;
414
+ wallet.#readOnly = true;
415
+ return wallet;
416
+ } catch (e) {
417
+ release();
418
+ throw e;
419
+ }
420
+ }
421
+
422
+ /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
423
+ static async #begin(
424
+ base: Omit<Ctx, 'state' | 'save' | 'lastFullRefreshMs'>,
425
+ {
426
+ persist,
427
+ saved,
428
+ key,
429
+ from,
430
+ }: { persist: OpenOptions['persist']; saved: Uint8Array | null | undefined; key: Uint8Array; from?: bigint | Date },
431
+ unlock?: () => Promise<Uint8Array>,
432
+ ): Promise<Wallet> {
433
+ const loaded = saved?.length ? openState(key, saved) : null;
307
434
  // never started over in place: the first save would overwrite what it held, operations in flight included
308
435
  if (saved?.length && !loaded) {
309
436
  fail(
310
437
  'invalid',
311
- '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',
438
+ 'the saved state does not open with this key (another seed or view key, a newer version, or damaged): clear it to start over, which finds what it held again',
312
439
  );
313
440
  }
314
- if (loaded && loaded.address.toLowerCase() !== address.toLowerCase())
441
+ if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
315
442
  fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
316
- const state: WalletState = loaded ?? emptyWalletState(address);
317
- const ctx: Ctx = {
318
- network: opts.network,
319
- seed: opts.seed,
320
- address,
321
- walletClient: opts.walletClient,
322
- storage,
323
- view,
324
- chains,
325
- state,
326
- save: () => opts.persist.save(sealState(opts.seed, state)),
327
- lastFullRefreshMs: 0,
328
- };
329
- if (!loaded) await start(ctx, restoring ? 0n : opts.birthday);
330
- const wallet = new Wallet(ctx);
331
- wallet.#release = release;
332
- wallet.#referrer = opts.referrer;
443
+ const state: WalletState = loaded ?? emptyWalletState(base.address);
444
+ const ctx: Ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
445
+ if (!loaded) await start(ctx, from);
446
+ const wallet = new Wallet(ctx, unlock);
333
447
  // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
334
448
  // and history work before the first sync
335
- const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
449
+ const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
336
450
  wallet.#tokens = tokens.flat();
451
+ keyAll(ctx, wallet.#tokens);
337
452
  return wallet;
338
453
  }
339
454
 
455
+ /** Holds `seed`, asked of `#unlock`, once it is checked to be this wallet's. */
456
+ #hold(seed: Uint8Array): void {
457
+ if ((core.spendKey(seed) as SpendKey).publicKey !== owner(this.#ctx)!.publicKey)
458
+ fail('invalid', "the seed given is another wallet's");
459
+ this.#ctx.seed = seed;
460
+ keyAll(this.#ctx, this.#tokens);
461
+ }
462
+
463
+ /** Within a task: the seed held, asked of `#unlock` if need be. */
464
+ async #seed(): Promise<void> {
465
+ if (!this.#ctx.seed) this.#hold(await this.#unlock!());
466
+ }
467
+
468
+ /** At the end of a sync: lets the seed go when it was opened with a function for it, nothing waits behind the sync,
469
+ * and no payment, withdrawal or consolidation is in flight, as each may sign again (a JoinSplit that reverts is
470
+ * proved again, and every merge round is signed). */
471
+ #dropSeed(): void {
472
+ if (!this.#unlock || this.#busy > 1) return;
473
+ if (this.#ctx.state.ops.some((o) => o.status === 'pending' && o.kind !== 'deposit')) return;
474
+ this.#ctx.seed = null;
475
+ }
476
+
477
+ /** `#exclusive` for a call that `needs` the seed: held first, asked of `#unlock` if need be before the call waits on
478
+ * anything else (a browser shows a passkey prompt only in the click that asked), and queued in the step that took it,
479
+ * so that a sync ending meanwhile does not let it go. */
480
+ async #seeded<T>(f: () => Promise<T>, needs = true): Promise<T> {
481
+ this.#writable();
482
+ if (needs && !this.#ctx.seed) this.#hold(await this.#unlock!());
483
+ return this.#exclusive(f);
484
+ }
485
+
486
+ /** Refuses a call that spends or writes to storage, in a wallet that watches. */
487
+ #writable(): void {
488
+ if (this.#readOnly) fail('read-only', 'this wallet watches its account by its view key: open it with its seed');
489
+ }
490
+
340
491
  /** Runs `f` after everything queued before it: one state, changed by one task at a time. */
341
492
  #exclusive<T>(f: () => Promise<T>): Promise<T> {
342
493
  this.#busy++;
@@ -372,13 +523,15 @@ export class Wallet {
372
523
 
373
524
  /** Catches up: new notes, deposits, spends and roots, then advances every operation in flight. A chain or server that
374
525
  * fails holds back only what needs it, and the first error is thrown once the rest is done. Settles before the burn
375
- * addresses are topped up, which can take a phone tens of seconds. */
526
+ * addresses are topped up, which can take a phone some seconds. */
376
527
  sync(): Promise<void> {
377
528
  return new Promise((resolve, reject) => {
378
529
  this.#exclusive(async () => {
379
530
  await this.#catchUp().then(resolve, reject);
380
- // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout
381
- await topUpBurns(this.#ctx, this.#closed.signal).catch(() => {}); // tried again after the next sync
531
+ // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout;
532
+ // tried again after the next sync. A wallet that watches records none, as it writes nothing.
533
+ if (!this.#readOnly) await topUpBurns(this.#ctx, this.#closed.signal).catch(() => {});
534
+ this.#dropSeed();
382
535
  }).catch(reject); // closed before it ran
383
536
  });
384
537
  }
@@ -424,7 +577,7 @@ export class Wallet {
424
577
  if (errors.length > 0) throw errors[0];
425
578
  }
426
579
 
427
- /** Syncs on its own: every 15 s while something is under way, every 5 min otherwise. Returns a stop function. */
580
+ /** Syncs on its own: every 3 s while something is under way, every 10 s otherwise. Returns a stop function. */
428
581
  autoSync(onChange?: () => void, onError?: (e: unknown) => void): () => void {
429
582
  let stopped = false;
430
583
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -437,7 +590,7 @@ export class Wallet {
437
590
  }
438
591
  if (stopped || this.#closed.signal.aborted) return;
439
592
  const busy = this.#ctx.state.ops.some((o) => o.status === 'pending' && Date.now() - o.createdMs < WAIT_MS * 4);
440
- timer = setTimeout(tick, busy ? 15_000 : 300_000);
593
+ timer = setTimeout(tick, busy ? 3_000 : 10_000);
441
594
  };
442
595
  void tick();
443
596
  return () => {
@@ -460,18 +613,22 @@ export class Wallet {
460
613
 
461
614
  balances(): Balance[] {
462
615
  const coins = this.coins();
616
+ // storage says which notes to look for, not that they come: a payer writes its notes there before its JoinSplit
617
+ // leaves, and one that never lands leaves them behind. So a note counts once it is a coin (its commitment read);
618
+ // before that, only a note this device's own operation under way is making counts, as incoming. A zero-value
619
+ // change, there to carry its payment's receipt, never counts
620
+ const making = new Set(
621
+ this.#ctx.state.ops.flatMap((o) =>
622
+ o.status === 'pending' && o.kind !== 'deposit' ? (o.data.changes ?? []) : [],
623
+ ),
624
+ );
463
625
  return this.#tokens.map((token) => {
464
626
  const { chainId, tokenId } = token;
465
627
  const b: Balance = { token, spendable: 0n, pending: [], blocked: 0n };
466
- // a merge that never landed (another device spent an input first) stops counting after a day; a zero-value
467
- // change, there to carry its payment's receipt, never counts. A note is a coin once its commitment is read, so
468
- // until then it is incoming, as a payment's change is between its relay and the next sync
469
628
  for (const n of this.#ctx.state.notes.filter(
470
- (n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n,
629
+ (n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n && making.has(n.salt),
471
630
  )) {
472
- const stale =
473
- n.receipt?.kind === 'merged' && n.atNs !== null && Date.now() - Number(n.atNs / 1_000_000n) > DAY_MS;
474
- if (!n.found.some((f) => f.confirmed) && !stale) b.pending.push({ amount: n.value, reason: 'incoming' });
631
+ if (!n.found.some((f) => f.confirmed)) b.pending.push({ amount: n.value, reason: 'incoming' });
475
632
  }
476
633
  for (const c of coins.filter((c) => c.token === token)) {
477
634
  if (c.state === 'spendable') b.spendable += c.amount;
@@ -483,13 +640,14 @@ export class Wallet {
483
640
  }
484
641
 
485
642
  /** Every unspent note and deposit on chain, and where each stands, as of opening or the last sync. A note announced
486
- * but not on chain yet is not one: `balances()` counts it as incoming. */
643
+ * but not read on chain yet is not one: `balances()` counts it as incoming if this device's operation is making it. */
487
644
  coins(): CoinInfo[] {
488
645
  const size = (chainId: bigint) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
489
646
  const unproved = (chainId: bigint) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
490
647
  const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
491
- // spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it (a reorg that drops
492
- // it brings them back as spending, and the operation relays it again)
648
+ // spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
649
+ // nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
650
+ // back as spending, and the operation relays it again)
493
651
  const landed = new Set(
494
652
  this.#ctx.state.ops.flatMap((o) =>
495
653
  o.kind !== 'deposit' && o.data.step?.stage === 'relayed'
@@ -500,7 +658,7 @@ export class Wallet {
500
658
  const out: CoinInfo[] = [];
501
659
  // a zero-value change carries its payment's receipt, and is no coin
502
660
  for (const n of this.#ctx.state.notes.filter((n) => n.value > 0n)) {
503
- for (const f of n.found.filter((f) => !f.spent)) {
661
+ for (const f of n.found.filter(unspent)) {
504
662
  const id = `${n.chainId}:${f.index}`;
505
663
  // a 4-byte tag alone matches someone else's now and then: a coin once its commitment is read
506
664
  if (!f.confirmed || landed.has(id)) continue;
@@ -518,7 +676,7 @@ export class Wallet {
518
676
  }
519
677
  for (const d of this.#ctx.state.deposits) {
520
678
  // a coin once its commitment is read, which names the amount
521
- for (const f of d.found.filter((f) => !f.spent && f.amount !== null)) {
679
+ for (const f of d.found.filter((f) => unspent(f) && f.amount !== null)) {
522
680
  const id = `${d.chainId}:${f.index}`;
523
681
  if (landed.has(id)) continue;
524
682
  const state =
@@ -754,6 +912,7 @@ export class Wallet {
754
912
  * addresses. */
755
913
  posKey(): Promise<Hex> {
756
914
  return this.#exclusive(async () => {
915
+ this.#writable();
757
916
  const ctx = this.#ctx;
758
917
  const tokens = await tokenIds(ctx);
759
918
  let n = 0;
@@ -768,7 +927,7 @@ export class Wallet {
768
927
  /** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
769
928
  * rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
770
929
  rageExit(id: string): Promise<Hex[]> {
771
- return this.#exclusive(async () => {
930
+ return this.#seeded(async () => {
772
931
  const [chain, index] = id.split(':');
773
932
  const record =
774
933
  this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
@@ -874,22 +1033,27 @@ export class Wallet {
874
1033
  * With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
875
1034
  * asset: a plain transfer into the burn address, with no wrap and no approval.
876
1035
  *
1036
+ * With `from`, another wallet of the user's (a browser wallet) pays instead of the account: its balance is checked,
1037
+ * it approves and wraps, and an exit refunds it there. The account pays nothing and signs nothing.
1038
+ *
877
1039
  * Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
878
1040
  * `onStep` hears what it waits on meanwhile, for an app to show. */
879
- deposit(opts: {
1041
+ async deposit(opts: {
880
1042
  token: Token;
881
1043
  amount: bigint;
882
1044
  to?: Address;
883
1045
  wrapped?: boolean;
1046
+ from?: WalletClient;
884
1047
  onStep?: (step: DepositStep) => void;
885
1048
  }): Promise<Operation> {
1049
+ this.#writable();
1050
+ if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
1051
+ const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
886
1052
  const step = opts.onStep ?? (() => {});
887
1053
  if (this.#busy > 0) step('queued');
888
1054
  return this.#exclusive(async () => {
889
1055
  const { token, amount } = opts;
890
- if (amount <= 0n) fail('invalid', 'the amount must be positive');
891
- const wc = this.#ctx.walletClient;
892
- const account = wc.account!;
1056
+ const account = payer ?? this.#ctx.account;
893
1057
  const client = chainCtx(this.#ctx, token.chainId).chain.client;
894
1058
  const { wrapped } = opts;
895
1059
  const native = !wrapped && token.underlying.toLowerCase() === NATIVE.toLowerCase();
@@ -906,7 +1070,7 @@ export class Wallet {
906
1070
  const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
907
1071
  fail(
908
1072
  'insufficient-funds',
909
- `the account holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`,
1073
+ `${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`,
910
1074
  );
911
1075
  }
912
1076
  const address = opts.to
@@ -920,8 +1084,7 @@ export class Wallet {
920
1084
  );
921
1085
  await this.#ctx.save();
922
1086
  try {
923
- if ((await wc.getChainId()) !== Number(token.chainId))
924
- await guard(wc.switchChain({ id: Number(token.chainId) }));
1087
+ const wc = opts.from ? await switched(opts.from, token.chainId) : await walletOn(this.#ctx, token.chainId);
925
1088
  if (!native && !wrapped) {
926
1089
  const allowance = await client.readContract({
927
1090
  address: token.underlying,
@@ -1024,6 +1187,7 @@ export class Wallet {
1024
1187
  const step = opts.onStep ?? (() => {});
1025
1188
  if (this.#busy > 0) step('queued');
1026
1189
  return this.#exclusive(async () => {
1190
+ this.#writable();
1027
1191
  const c = chainCtx(this.#ctx, token.chainId);
1028
1192
  const record = await this.#burnAddress(token, null, () => step('burn-address'));
1029
1193
  const { depositAddress, fee } = await registerDeposit(
@@ -1056,19 +1220,21 @@ export class Wallet {
1056
1220
  * nothing; it names neither this address nor its public key. Anyone who gets the code can pay it, and see what its
1057
1221
  * other payers paid privately: give each payer its own. It is checksummed text (bech32m, `zerc201…`). */
1058
1222
  receiveCode(token: Token): Promise<string> {
1059
- return this.#exclusive(async () => {
1060
- const ctx = this.#ctx;
1223
+ const ctx = this.#ctx;
1224
+ const code = (index: number) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
1225
+ // the seed is asked for only past the codes derived ahead
1226
+ const derived = !!owner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
1227
+ return this.#seeded(async () => {
1061
1228
  const { burnIndex, address } = await this.#burnAddress(token);
1229
+ if (!owner(ctx, code(burnIndex))) await this.#seed(); // calls queued before took the next ones
1062
1230
  return encodeCode({
1063
1231
  chainId: token.chainId,
1064
1232
  tokenId: token.tokenId,
1065
1233
  burnAddress: hexToBytes(address),
1066
- publicKey: hexToBytes(
1067
- spendKey(ctx, { chainId: token.chainId, tokenId: token.tokenId, index: burnIndex }).publicKey,
1068
- ),
1234
+ publicKey: hexToBytes(owner(ctx, code(burnIndex))!.publicKey),
1069
1235
  key: codeKey(ctx.view.seed, token.chainId, token.tokenId, burnIndex),
1070
1236
  });
1071
- });
1237
+ }, !derived);
1072
1238
  }
1073
1239
 
1074
1240
  /** What a send or withdrawal of `amount` would cost now, or a consolidation of what the wallet holds. A send or
@@ -1153,10 +1319,11 @@ export class Wallet {
1153
1319
  * what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
1154
1320
  * `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
1155
1321
  * relayer names its fee, so a quote the user saw is the cap. */
1156
- send(opts: { to: string; token: Token; amount: bigint; maxFee: bigint }): Promise<Operation> {
1157
- return this.#exclusive(async () => {
1158
- const { token, amount } = opts;
1159
- if (amount <= 0n) fail('invalid', 'the amount must be positive');
1322
+ async send(opts: { to: string; token: Token; amount: bigint; maxFee: bigint }): Promise<Operation> {
1323
+ const { token, amount } = opts;
1324
+ if (amount <= 0n) fail('invalid', 'the amount must be positive');
1325
+ // the JoinSplit is proved by a later sync, which holds the seed until the payment is done
1326
+ return this.#seeded(async () => {
1160
1327
  let to: Destination;
1161
1328
  if (isAddress(opts.to)) {
1162
1329
  const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
@@ -1188,7 +1355,7 @@ export class Wallet {
1188
1355
  * touched; one still settling is waited for. `maxFee` caps the fees of every merge together,
1189
1356
  * `fees({ kind: 'consolidate' }).fee`. */
1190
1357
  consolidate(opts: { token: Token; coins?: string[]; maxFee: bigint }): Promise<Operation> {
1191
- return this.#exclusive(async () => {
1358
+ return this.#seeded(async () => {
1192
1359
  if (!this.#ctx.network.mergeProver) fail('invalid', 'this network has no merge prover');
1193
1360
  const to: Destination = { kind: 'self' };
1194
1361
  const data: SpendData = { to, maxFee: opts.maxFee, step: null, paid: 0n };
@@ -1215,10 +1382,16 @@ export class Wallet {
1215
1382
  /** Withdraws `amount` publicly to `to`, as zERC20 or with `unwrap` the underlying asset itself, so `to` needs no gas
1216
1383
  * to unwrap. One JoinSplit, as `send`, and as there `to` gets all of `amount` and `maxFee` caps what is paid on top
1217
1384
  * (`fees({ kind: 'withdraw' }).fee`). */
1218
- withdraw(opts: { to: Address; token: Token; amount: bigint; maxFee: bigint; unwrap?: boolean }): Promise<Operation> {
1219
- return this.#exclusive(async () => {
1220
- if (!isAddress(opts.to)) fail('invalid', `${String(opts.to)} is not an address`);
1221
- if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
1385
+ async withdraw(opts: {
1386
+ to: Address;
1387
+ token: Token;
1388
+ amount: bigint;
1389
+ maxFee: bigint;
1390
+ unwrap?: boolean;
1391
+ }): Promise<Operation> {
1392
+ if (!isAddress(opts.to)) fail('invalid', `${String(opts.to)} is not an address`);
1393
+ if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
1394
+ return this.#seeded(async () => {
1222
1395
  const to: Destination = { kind: 'public', address: opts.to, unwrap: opts.unwrap };
1223
1396
  return this.#newPayment('withdraw', opts.token, opts.amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
1224
1397
  });
@@ -1239,15 +1412,25 @@ export class Wallet {
1239
1412
  }
1240
1413
  }
1241
1414
 
1415
+ /** Each chain of `network`, with its servers. */
1416
+ const chainsOf = (network: Network): ChainCtx[] =>
1417
+ network.chains.map((config) => ({
1418
+ config,
1419
+ chain: new Chain(config),
1420
+ indexer: new Indexer(config.indexer),
1421
+ asp: new Asp(config.asp),
1422
+ }));
1423
+
1242
1424
  /** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
1243
- * other's state and spend the same notes. Nothing where there are no Web Locks. */
1244
- export async function lockWallet(address: Address): Promise<() => void> {
1425
+ * other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
1426
+ * Nothing where there are no Web Locks. */
1427
+ export async function lockWallet(address: Address, kind: 'wallet' | 'view' = 'wallet'): Promise<() => void> {
1245
1428
  const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
1246
1429
  if (!locks) return () => {};
1247
1430
  let release = () => {};
1248
1431
  const held = new Promise<void>((r) => (release = r));
1249
1432
  const got = await new Promise<boolean>((resolve) => {
1250
- void locks.request(`zerc20-wallet:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1433
+ void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1251
1434
  resolve(lock !== null);
1252
1435
  return lock ? held : undefined;
1253
1436
  });