@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/dist/wallet.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // The wallet an app works with. Amounts are bigints in a token's base units.
2
- import { CanisterClient, codeKey, commit, decodeCode, encodeCode, lookupRecipient, nextPosLane, planDeposit, planPosLane, posKey, registeredViewKey, } from '@zerc20/storage-sdk';
3
- import { bytesToHex, formatUnits, hexToBytes, isAddress, numberToHex, zeroAddress, } from 'viem';
2
+ import { CanisterClient, codeKey, commit, decodeCode, encodeCode, lookupRecipient, nextPosLane, notifyKey, planDeposit, planPosLane, posKey, registeredViewKey, } from '@zerc20/storage-sdk';
3
+ import { bytesToHex, formatUnits, getAddress, hexToBytes, isAddress, numberToHex, zeroAddress, } from 'viem';
4
+ import { toAccount } from 'viem/accounts';
4
5
  import { Chain, MAX_REFERRAL_BPS, NATIVE, erc20Abi, tokenAbi } from './chain.js';
5
- import { chainCtx, chainState, spendKey, walletSees } from './context.js';
6
+ import { chainCtx, chainState, owner, switched, walletOn, walletSees, } from './context.js';
6
7
  import { burnAddressFor, core, initCore } from './core.js';
7
8
  import { WalletError, fail, guard } from './errors.js';
8
9
  import { checkNetwork } from './network.js';
@@ -10,9 +11,9 @@ import { Asp, Indexer, PAGE } from './servers.js';
10
11
  import { rageExit } from './rageExit.js';
11
12
  import { registerDeposit, relayerFees } from './services.js';
12
13
  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;
14
+ import { emptyWalletState, openState, sealState, stateKey, unspent, } from './state.js';
15
+ import { WAIT_MS, commitmentTx, keyAll, nextBurn, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
16
+ import { decodeViewKey, encodeViewKey } from './viewKey.js';
16
17
  /** The ledger's bound outside an emergency. */
17
18
  const NO_BOUND = 2n ** 64n - 1n;
18
19
  export class Wallet {
@@ -24,9 +25,12 @@ export class Wallet {
24
25
  #busy = 0;
25
26
  #release = () => { };
26
27
  #referrer;
28
+ #unlock;
29
+ #readOnly = false;
27
30
  #closed = new AbortController();
28
- constructor(ctx) {
31
+ constructor(ctx, unlock) {
29
32
  this.#ctx = ctx;
33
+ this.#unlock = unlock;
30
34
  }
31
35
  /** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
32
36
  * queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
@@ -38,12 +42,19 @@ export class Wallet {
38
42
  get address() {
39
43
  return this.#ctx.address;
40
44
  }
45
+ /** The account's view key, for `watch` to follow it with on another device: whoever holds it sees what this wallet
46
+ * shows, except the private payments to receive codes, and can spend none of it. Checksummed text (bech32m,
47
+ * `zerc20view1…`). */
48
+ viewKey() {
49
+ return encodeViewKey(this.#ctx.view, owner(this.#ctx));
50
+ }
51
+ /** Whether this wallet only watches its account (`watch`): it shows what the account holds and spends nothing. */
52
+ get readOnly() {
53
+ return this.#readOnly;
54
+ }
41
55
  /** Opens the wallet. The first time on this address, registers it for payments: one signature. */
42
56
  static async open(opts) {
43
- await initCore();
44
57
  checkNetwork(opts.network);
45
- if (opts.seed.length !== 32)
46
- fail('invalid', 'the seed is 32 bytes');
47
58
  // the factory refuses these, which the relayer would report as its own failure
48
59
  const r = opts.referrer;
49
60
  if (r) {
@@ -52,67 +63,156 @@ export class Wallet {
52
63
  if (!Number.isInteger(r.bps) || r.bps < 0 || r.bps > MAX_REFERRAL_BPS)
53
64
  fail('invalid', `a referrer takes 0 to ${MAX_REFERRAL_BPS} bps, not ${r.bps}`);
54
65
  }
55
- const account = opts.walletClient.account ?? fail('invalid', 'the wallet client has no account');
66
+ if (!opts.account === !opts.walletClient)
67
+ fail('invalid', 'give an account or a wallet client, one of the two');
68
+ const account = opts.account ?? opts.walletClient.account ?? fail('invalid', 'the wallet client has no account');
69
+ // asked once the options are checked, and before anything is waited on: a browser shows a passkey prompt only in
70
+ // the click that opened
71
+ const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
72
+ if (seed.length !== 32)
73
+ fail('invalid', 'the seed is 32 bytes');
74
+ await initCore();
56
75
  const release = await lockWallet(account.address);
57
76
  try {
58
- return await Wallet.#open(opts, account, release);
77
+ return await Wallet.#open(opts, seed, account, release);
59
78
  }
60
79
  catch (e) {
61
80
  release();
62
81
  throw e;
63
82
  }
64
83
  }
65
- static async #open(opts, account, release) {
84
+ static async #open(opts, seed, account, release) {
66
85
  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
- }));
86
+ const chains = chainsOf(opts.network);
73
87
  const code = await chains[0]?.chain.client.getCode({ address });
74
88
  // an EIP-7702 delegated account is still an EOA that signs
75
89
  if (code && code !== '0x' && !code.startsWith('0xef0100'))
76
90
  fail('unsupported-account', 'contract accounts cannot sign for a wallet');
77
- const signer = async (message) => hexToBytes(await guard(opts.walletClient.signMessage({ account, message })));
91
+ const signer = async (message) => hexToBytes(await guard(account.type === 'local'
92
+ ? account.signMessage({ message })
93
+ : opts.walletClient.signMessage({ account, message })));
78
94
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
79
- const publicKey = hexToBytes(core.spendKey(opts.seed).publicKey);
95
+ const publicKey = hexToBytes(core.spendKey(seed).publicKey);
80
96
  const saved = await opts.persist.load();
81
97
  // registered already: used before, here or on another device, so its past is looked for
82
98
  // ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
83
99
  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 = {
100
+ const view = await registeredViewKey(storage, seed, signer, hexToBytes(address), publicKey);
101
+ const wallet = await Wallet.#begin({
94
102
  network: opts.network,
95
- seed: opts.seed,
103
+ seed,
104
+ owners: new Map(),
96
105
  address,
97
- walletClient: opts.walletClient,
106
+ account,
107
+ walletClient: opts.walletClient ?? null,
98
108
  storage,
99
109
  view,
100
110
  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);
111
+ }, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday }, typeof opts.seed === 'function' ? opts.seed : undefined);
108
112
  wallet.#release = release;
109
113
  wallet.#referrer = opts.referrer;
114
+ return wallet;
115
+ }
116
+ /** Opens an account to follow by its view key (`viewKey()`): its balances, coins and history as its own wallet shows
117
+ * them, less the private payments to its receive codes, whose keys only the seed derives. It spends and writes
118
+ * nothing: a call that would fails with `read-only`. The key must be the account's current one on this network. */
119
+ static async watch(opts) {
120
+ checkNetwork(opts.network);
121
+ await initCore();
122
+ const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
123
+ const address = getAddress(bytesToHex(view.address));
124
+ const release = await lockWallet(address, 'view');
125
+ try {
126
+ const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
127
+ // what the account signed: a nullifying key that is not the account's would show spent coins as unspent
128
+ const r = await lookupRecipient(storage, view.address);
129
+ if (!r || bytesToHex(r.publicKey) !== owner.publicKey || bytesToHex(r.notifyKey) !== bytesToHex(notifyKey(view)))
130
+ fail('invalid', `not ${address}'s view key on ${opts.network.name}, or one it has replaced`);
131
+ const wallet = await Wallet.#begin({
132
+ network: opts.network,
133
+ seed: null,
134
+ owners: new Map([['', owner]]),
135
+ address,
136
+ // signs nothing: every call that would send is refused first
137
+ account: toAccount(address),
138
+ walletClient: null,
139
+ storage,
140
+ view,
141
+ chains: chainsOf(opts.network),
142
+ },
143
+ // registered, so its past is looked for
144
+ {
145
+ persist: opts.persist,
146
+ saved: await opts.persist.load(),
147
+ key: stateKey(view.seed),
148
+ from: opts.birthday ?? 0n,
149
+ });
150
+ wallet.#release = release;
151
+ wallet.#readOnly = true;
152
+ return wallet;
153
+ }
154
+ catch (e) {
155
+ release();
156
+ throw e;
157
+ }
158
+ }
159
+ /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
160
+ static async #begin(base, { persist, saved, key, from, }, unlock) {
161
+ const loaded = saved?.length ? openState(key, saved) : null;
162
+ // never started over in place: the first save would overwrite what it held, operations in flight included
163
+ if (saved?.length && !loaded) {
164
+ fail('invalid', 'the saved state does not open with this key (another seed or view key, a newer version, or damaged): clear it to start over, which finds what it held again');
165
+ }
166
+ if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
167
+ fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
168
+ const state = loaded ?? emptyWalletState(base.address);
169
+ const ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
170
+ if (!loaded)
171
+ await start(ctx, from);
172
+ const wallet = new Wallet(ctx, unlock);
110
173
  // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
111
174
  // and history work before the first sync
112
- const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
175
+ const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
113
176
  wallet.#tokens = tokens.flat();
177
+ keyAll(ctx, wallet.#tokens);
114
178
  return wallet;
115
179
  }
180
+ /** Holds `seed`, asked of `#unlock`, once it is checked to be this wallet's. */
181
+ #hold(seed) {
182
+ if (core.spendKey(seed).publicKey !== owner(this.#ctx).publicKey)
183
+ fail('invalid', "the seed given is another wallet's");
184
+ this.#ctx.seed = seed;
185
+ keyAll(this.#ctx, this.#tokens);
186
+ }
187
+ /** Within a task: the seed held, asked of `#unlock` if need be. */
188
+ async #seed() {
189
+ if (!this.#ctx.seed)
190
+ this.#hold(await this.#unlock());
191
+ }
192
+ /** At the end of a sync: lets the seed go when it was opened with a function for it, nothing waits behind the sync,
193
+ * and no payment, withdrawal or consolidation is in flight, as each may sign again (a JoinSplit that reverts is
194
+ * proved again, and every merge round is signed). */
195
+ #dropSeed() {
196
+ if (!this.#unlock || this.#busy > 1)
197
+ return;
198
+ if (this.#ctx.state.ops.some((o) => o.status === 'pending' && o.kind !== 'deposit'))
199
+ return;
200
+ this.#ctx.seed = null;
201
+ }
202
+ /** `#exclusive` for a call that `needs` the seed: held first, asked of `#unlock` if need be before the call waits on
203
+ * anything else (a browser shows a passkey prompt only in the click that asked), and queued in the step that took it,
204
+ * so that a sync ending meanwhile does not let it go. */
205
+ async #seeded(f, needs = true) {
206
+ this.#writable();
207
+ if (needs && !this.#ctx.seed)
208
+ this.#hold(await this.#unlock());
209
+ return this.#exclusive(f);
210
+ }
211
+ /** Refuses a call that spends or writes to storage, in a wallet that watches. */
212
+ #writable() {
213
+ if (this.#readOnly)
214
+ fail('read-only', 'this wallet watches its account by its view key: open it with its seed');
215
+ }
116
216
  /** Runs `f` after everything queued before it: one state, changed by one task at a time. */
117
217
  #exclusive(f) {
118
218
  this.#busy++;
@@ -145,13 +245,16 @@ export class Wallet {
145
245
  }
146
246
  /** Catches up: new notes, deposits, spends and roots, then advances every operation in flight. A chain or server that
147
247
  * 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. */
248
+ * addresses are topped up, which can take a phone some seconds. */
149
249
  sync() {
150
250
  return new Promise((resolve, reject) => {
151
251
  this.#exclusive(async () => {
152
252
  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
253
+ // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout;
254
+ // tried again after the next sync. A wallet that watches records none, as it writes nothing.
255
+ if (!this.#readOnly)
256
+ await topUpBurns(this.#ctx, this.#closed.signal).catch(() => { });
257
+ this.#dropSeed();
155
258
  }).catch(reject); // closed before it ran
156
259
  });
157
260
  }
@@ -193,7 +296,7 @@ export class Wallet {
193
296
  if (errors.length > 0)
194
297
  throw errors[0];
195
298
  }
196
- /** Syncs on its own: every 15 s while something is under way, every 5 min otherwise. Returns a stop function. */
299
+ /** Syncs on its own: every 3 s while something is under way, every 10 s otherwise. Returns a stop function. */
197
300
  autoSync(onChange, onError) {
198
301
  let stopped = false;
199
302
  let timer;
@@ -208,7 +311,7 @@ export class Wallet {
208
311
  if (stopped || this.#closed.signal.aborted)
209
312
  return;
210
313
  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);
314
+ timer = setTimeout(tick, busy ? 3_000 : 10_000);
212
315
  };
213
316
  void tick();
214
317
  return () => {
@@ -226,15 +329,16 @@ export class Wallet {
226
329
  }
227
330
  balances() {
228
331
  const coins = this.coins();
332
+ // storage says which notes to look for, not that they come: a payer writes its notes there before its JoinSplit
333
+ // leaves, and one that never lands leaves them behind. So a note counts once it is a coin (its commitment read);
334
+ // before that, only a note this device's own operation under way is making counts, as incoming. A zero-value
335
+ // change, there to carry its payment's receipt, never counts
336
+ const making = new Set(this.#ctx.state.ops.flatMap((o) => o.status === 'pending' && o.kind !== 'deposit' ? (o.data.changes ?? []) : []));
229
337
  return this.#tokens.map((token) => {
230
338
  const { chainId, tokenId } = token;
231
339
  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)
340
+ for (const n of this.#ctx.state.notes.filter((n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n && making.has(n.salt))) {
341
+ if (!n.found.some((f) => f.confirmed))
238
342
  b.pending.push({ amount: n.value, reason: 'incoming' });
239
343
  }
240
344
  for (const c of coins.filter((c) => c.token === token)) {
@@ -249,20 +353,21 @@ export class Wallet {
249
353
  });
250
354
  }
251
355
  /** 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. */
356
+ * but not read on chain yet is not one: `balances()` counts it as incoming if this device's operation is making it. */
253
357
  coins() {
254
358
  const size = (chainId) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
255
359
  const unproved = (chainId) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
256
360
  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)
361
+ // spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
362
+ // nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
363
+ // back as spending, and the operation relays it again)
259
364
  const landed = new Set(this.#ctx.state.ops.flatMap((o) => o.kind !== 'deposit' && o.data.step?.stage === 'relayed'
260
365
  ? inputsOf(o).map((c) => `${o.chainId}:${c.index}`)
261
366
  : []));
262
367
  const out = [];
263
368
  // a zero-value change carries its payment's receipt, and is no coin
264
369
  for (const n of this.#ctx.state.notes.filter((n) => n.value > 0n)) {
265
- for (const f of n.found.filter((f) => !f.spent)) {
370
+ for (const f of n.found.filter(unspent)) {
266
371
  const id = `${n.chainId}:${f.index}`;
267
372
  // a 4-byte tag alone matches someone else's now and then: a coin once its commitment is read
268
373
  if (!f.confirmed || landed.has(id))
@@ -281,7 +386,7 @@ export class Wallet {
281
386
  }
282
387
  for (const d of this.#ctx.state.deposits) {
283
388
  // 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)) {
389
+ for (const f of d.found.filter((f) => unspent(f) && f.amount !== null)) {
285
390
  const id = `${d.chainId}:${f.index}`;
286
391
  if (landed.has(id))
287
392
  continue;
@@ -505,6 +610,7 @@ export class Wallet {
505
610
  * addresses. */
506
611
  posKey() {
507
612
  return this.#exclusive(async () => {
613
+ this.#writable();
508
614
  const ctx = this.#ctx;
509
615
  const tokens = await tokenIds(ctx);
510
616
  let n = 0;
@@ -518,7 +624,7 @@ export class Wallet {
518
624
  /** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
519
625
  * rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
520
626
  rageExit(id) {
521
- return this.#exclusive(async () => {
627
+ return this.#seeded(async () => {
522
628
  const [chain, index] = id.split(':');
523
629
  const record = this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
524
630
  fail('invalid', `no deposit ${id}`);
@@ -615,18 +721,22 @@ export class Wallet {
615
721
  * With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
616
722
  * asset: a plain transfer into the burn address, with no wrap and no approval.
617
723
  *
724
+ * With `from`, another wallet of the user's (a browser wallet) pays instead of the account: its balance is checked,
725
+ * it approves and wraps, and an exit refunds it there. The account pays nothing and signs nothing.
726
+ *
618
727
  * Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
619
728
  * `onStep` hears what it waits on meanwhile, for an app to show. */
620
- deposit(opts) {
729
+ async deposit(opts) {
730
+ this.#writable();
731
+ if (opts.amount <= 0n)
732
+ fail('invalid', 'the amount must be positive');
733
+ const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
621
734
  const step = opts.onStep ?? (() => { });
622
735
  if (this.#busy > 0)
623
736
  step('queued');
624
737
  return this.#exclusive(async () => {
625
738
  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;
739
+ const account = payer ?? this.#ctx.account;
630
740
  const client = chainCtx(this.#ctx, token.chainId).chain.client;
631
741
  const { wrapped } = opts;
632
742
  const native = !wrapped && token.underlying.toLowerCase() === NATIVE.toLowerCase();
@@ -641,7 +751,7 @@ export class Wallet {
641
751
  });
642
752
  if (held < amount) {
643
753
  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)}`);
754
+ fail('insufficient-funds', `${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`);
645
755
  }
646
756
  const address = opts.to
647
757
  ? await this.#burnFor(token, amount, opts.to, () => step('burn-address'))
@@ -649,8 +759,7 @@ export class Wallet {
649
759
  const op = this.#newOp('deposit', token, amount, opts.to ? { burnAddress: address, to: opts.to } : { burnAddress: address });
650
760
  await this.#ctx.save();
651
761
  try {
652
- if ((await wc.getChainId()) !== Number(token.chainId))
653
- await guard(wc.switchChain({ id: Number(token.chainId) }));
762
+ const wc = opts.from ? await switched(opts.from, token.chainId) : await walletOn(this.#ctx, token.chainId);
654
763
  if (!native && !wrapped) {
655
764
  const allowance = await client.readContract({
656
765
  address: token.underlying,
@@ -742,6 +851,7 @@ export class Wallet {
742
851
  if (this.#busy > 0)
743
852
  step('queued');
744
853
  return this.#exclusive(async () => {
854
+ this.#writable();
745
855
  const c = chainCtx(this.#ctx, token.chainId);
746
856
  const record = await this.#burnAddress(token, null, () => step('burn-address'));
747
857
  const { depositAddress, fee } = await registerDeposit(c.chain, token.address, record.address, this.address, this.#referrer);
@@ -767,17 +877,22 @@ export class Wallet {
767
877
  * nothing; it names neither this address nor its public key. Anyone who gets the code can pay it, and see what its
768
878
  * other payers paid privately: give each payer its own. It is checksummed text (bech32m, `zerc201…`). */
769
879
  receiveCode(token) {
770
- return this.#exclusive(async () => {
771
- const ctx = this.#ctx;
880
+ const ctx = this.#ctx;
881
+ const code = (index) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
882
+ // the seed is asked for only past the codes derived ahead
883
+ const derived = !!owner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
884
+ return this.#seeded(async () => {
772
885
  const { burnIndex, address } = await this.#burnAddress(token);
886
+ if (!owner(ctx, code(burnIndex)))
887
+ await this.#seed(); // calls queued before took the next ones
773
888
  return encodeCode({
774
889
  chainId: token.chainId,
775
890
  tokenId: token.tokenId,
776
891
  burnAddress: hexToBytes(address),
777
- publicKey: hexToBytes(spendKey(ctx, { chainId: token.chainId, tokenId: token.tokenId, index: burnIndex }).publicKey),
892
+ publicKey: hexToBytes(owner(ctx, code(burnIndex)).publicKey),
778
893
  key: codeKey(ctx.view.seed, token.chainId, token.tokenId, burnIndex),
779
894
  });
780
- });
895
+ }, !derived);
781
896
  }
782
897
  /** What a send or withdrawal of `amount` would cost now, or a consolidation of what the wallet holds. A send or
783
898
  * withdrawal no single JoinSplit can pay now throws as it would (`needs-consolidation`, `funds-settling`,
@@ -832,11 +947,12 @@ export class Wallet {
832
947
  * what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
833
948
  * `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
834
949
  * 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');
950
+ async send(opts) {
951
+ const { token, amount } = opts;
952
+ if (amount <= 0n)
953
+ fail('invalid', 'the amount must be positive');
954
+ // the JoinSplit is proved by a later sync, which holds the seed until the payment is done
955
+ return this.#seeded(async () => {
840
956
  let to;
841
957
  if (isAddress(opts.to)) {
842
958
  const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
@@ -866,7 +982,7 @@ export class Wallet {
866
982
  * touched; one still settling is waited for. `maxFee` caps the fees of every merge together,
867
983
  * `fees({ kind: 'consolidate' }).fee`. */
868
984
  consolidate(opts) {
869
- return this.#exclusive(async () => {
985
+ return this.#seeded(async () => {
870
986
  if (!this.#ctx.network.mergeProver)
871
987
  fail('invalid', 'this network has no merge prover');
872
988
  const to = { kind: 'self' };
@@ -895,12 +1011,12 @@ export class Wallet {
895
1011
  /** Withdraws `amount` publicly to `to`, as zERC20 or with `unwrap` the underlying asset itself, so `to` needs no gas
896
1012
  * to unwrap. One JoinSplit, as `send`, and as there `to` gets all of `amount` and `maxFee` caps what is paid on top
897
1013
  * (`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');
1014
+ async withdraw(opts) {
1015
+ if (!isAddress(opts.to))
1016
+ fail('invalid', `${String(opts.to)} is not an address`);
1017
+ if (opts.amount <= 0n)
1018
+ fail('invalid', 'the amount must be positive');
1019
+ return this.#seeded(async () => {
904
1020
  const to = { kind: 'public', address: opts.to, unwrap: opts.unwrap };
905
1021
  return this.#newPayment('withdraw', opts.token, opts.amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
906
1022
  });
@@ -920,16 +1036,24 @@ export class Wallet {
920
1036
  return this.#operation(op);
921
1037
  }
922
1038
  }
1039
+ /** Each chain of `network`, with its servers. */
1040
+ const chainsOf = (network) => network.chains.map((config) => ({
1041
+ config,
1042
+ chain: new Chain(config),
1043
+ indexer: new Indexer(config.indexer),
1044
+ asp: new Asp(config.asp),
1045
+ }));
923
1046
  /** 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) {
1047
+ * other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
1048
+ * Nothing where there are no Web Locks. */
1049
+ export async function lockWallet(address, kind = 'wallet') {
926
1050
  const locks = globalThis.navigator?.locks;
927
1051
  if (!locks)
928
1052
  return () => { };
929
1053
  let release = () => { };
930
1054
  const held = new Promise((r) => (release = r));
931
1055
  const got = await new Promise((resolve) => {
932
- void locks.request(`zerc20-wallet:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1056
+ void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
933
1057
  resolve(lock !== null);
934
1058
  return lock ? held : undefined;
935
1059
  });