@zerc20/wallet-sdk 0.4.0 → 0.6.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 (73) hide show
  1. package/LICENSE +17 -20
  2. package/README.md +2 -2
  3. package/dist/accountId.d.ts +5 -0
  4. package/dist/accountId.js +19 -0
  5. package/dist/accountId.js.map +1 -0
  6. package/dist/chain.d.ts +86 -9
  7. package/dist/chain.js +61 -33
  8. package/dist/chain.js.map +1 -1
  9. package/dist/context.d.ts +8 -2
  10. package/dist/context.js +15 -2
  11. package/dist/context.js.map +1 -1
  12. package/dist/core.d.ts +35 -5
  13. package/dist/core.js +98 -25
  14. package/dist/core.js.map +1 -1
  15. package/dist/errors.d.ts +20 -3
  16. package/dist/errors.js +7 -1
  17. package/dist/errors.js.map +1 -1
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +4 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/network.d.ts +4 -2
  22. package/dist/network.js +3 -1
  23. package/dist/network.js.map +1 -1
  24. package/dist/pos.d.ts +10 -1
  25. package/dist/pos.js +219 -55
  26. package/dist/pos.js.map +1 -1
  27. package/dist/prove.d.ts +3 -0
  28. package/dist/prove.js +9 -0
  29. package/dist/prove.js.map +1 -1
  30. package/dist/rageExit.d.ts +27 -4
  31. package/dist/rageExit.js +160 -49
  32. package/dist/rageExit.js.map +1 -1
  33. package/dist/seed.d.ts +4 -0
  34. package/dist/seed.js +13 -1
  35. package/dist/seed.js.map +1 -1
  36. package/dist/servers.d.ts +5 -0
  37. package/dist/servers.js +2 -0
  38. package/dist/servers.js.map +1 -1
  39. package/dist/services.d.ts +14 -6
  40. package/dist/services.js +35 -14
  41. package/dist/services.js.map +1 -1
  42. package/dist/spend.d.ts +53 -11
  43. package/dist/spend.js +171 -65
  44. package/dist/spend.js.map +1 -1
  45. package/dist/state.d.ts +21 -0
  46. package/dist/state.js.map +1 -1
  47. package/dist/sync.d.ts +10 -5
  48. package/dist/sync.js +49 -18
  49. package/dist/sync.js.map +1 -1
  50. package/dist/wallet.d.ts +132 -21
  51. package/dist/wallet.js +342 -91
  52. package/dist/wallet.js.map +1 -1
  53. package/package.json +3 -3
  54. package/src/accountId.ts +22 -0
  55. package/src/chain.ts +101 -49
  56. package/src/context.ts +27 -3
  57. package/src/core.ts +112 -26
  58. package/src/errors.ts +15 -2
  59. package/src/index.ts +15 -2
  60. package/src/network.ts +6 -3
  61. package/src/pos.ts +247 -61
  62. package/src/prove.ts +10 -0
  63. package/src/rageExit.ts +237 -61
  64. package/src/seed.ts +24 -1
  65. package/src/servers.ts +9 -1
  66. package/src/services.ts +36 -22
  67. package/src/spend.ts +188 -75
  68. package/src/state.ts +25 -1
  69. package/src/sync.ts +54 -19
  70. package/src/wallet.ts +437 -109
  71. package/wasm/zerc20_wallet_core.d.ts +1 -1
  72. package/wasm/zerc20_wallet_core.js +1 -1
  73. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
package/src/spend.ts CHANGED
@@ -6,9 +6,12 @@
6
6
  // and deposits into one note to self, proved by the merge prover (a wallet cannot: its key is 6.3 GB). More take
7
7
  // another merge, once a root is over the first one's note.
8
8
  //
9
- // A relayed JoinSplit or merge is done once its first input's nullifier is final, as sync sees it: the relayer's
10
- // answer alone could be a lie, or a reorg could drop the transaction. Only one that cannot land fails, which frees its
11
- // inputs: one whose nullifier is nowhere on chain, after it kept reverting.
9
+ // A relayed JoinSplit or merge is done once its change (a merge's note) is final where this wallet expects it, as sync
10
+ // sees it: only its own transaction puts that note there, as its salt is fresh and the proof binds it to the inputs'
11
+ // nullifiers. The relayer's answer alone could be a lie, a reorg could drop the transaction, and a spent input alone
12
+ // could be another device's doing: each device of a wallet locks its inputs in its own state. Only one that cannot
13
+ // land fails, which frees its inputs: one that kept reverting with no input spent, or one whose input another
14
+ // transaction spent.
12
15
 
13
16
  import { type Receipt, type Recipient, commit, decodeCode, planPayCode, planSelf, planSend } from '@zerc20/storage-sdk';
14
17
  import {
@@ -26,16 +29,8 @@ import { core } from './core.js';
26
29
  import { fail } from './errors.js';
27
30
  import { prove } from './prove.js';
28
31
  import { scan } from './scan.js';
29
- import {
30
- type Relayed,
31
- RelayUnknown,
32
- attest,
33
- proofOf,
34
- relay,
35
- relayMerge,
36
- relayerFees,
37
- requestProof,
38
- } from './services.js';
32
+ import type { Indexer } from './servers.js';
33
+ import { attest, proofOf, relay, relayMerge, relayerFees, requestProof } from './services.js';
39
34
  import { type NoteRecord, type OperationRecord, type Position, type SpendOperation, unspent } from './state.js';
40
35
  import { commitmentsAt, noteReceipt, storageState, takeRefresh, tokenIds } from './sync.js';
41
36
  import { aspPath, aspPaths, commitmentPath, commitmentPaths } from './tree.js';
@@ -74,9 +69,16 @@ export interface Step {
74
69
  request?: object;
75
70
  /** Reverts with no sign of it on chain: proved again with the same outputs a few times, then failed. */
76
71
  attempts: number;
77
- /** Once relayed: the indexer's nullifier count just after. Final nullifiers past it, and the input's still not among
78
- * them, means a reorg dropped the transaction. */
72
+ /** Once relayed: the block the relayer saw it mined in. */
73
+ relayedBlock?: number;
74
+ /** The indexer's nullifier count once it is past that block, or just after a relay that reverted on an input spent
75
+ * already: that leaves the step `attested`, not relayed again until its change tells whose transaction spent it.
76
+ * Final nullifiers past it, and its inputs still not among them, means whatever spent them is gone: a reorg dropped
77
+ * it, or the relayer never sent it. */
79
78
  relayedAt?: number;
79
+ /** The final commitment count once an input is seen spent. Final commitments past it, and the change still not among
80
+ * them, means another transaction spent the input, and this step can never land. */
81
+ spentAt?: number;
80
82
  }
81
83
 
82
84
  /** The most inputs one merge spends: the attester takes requests up to 2 MB, at 8 KiB of sealed witness per input
@@ -100,7 +102,13 @@ export interface MergeStep {
100
102
  /** Refused by the prover, or reverting with no sign of it on chain: built again with the same output a few times,
101
103
  * then failed. */
102
104
  attempts: number;
105
+ /** Built again after a relay of it reverted, which counts in `attempts` beside the prover's refusals: the request
106
+ * out may still land, so the step is not cancelled. */
107
+ sent?: true;
108
+ /** As a JoinSplit's, with `proved` where a JoinSplit is `attested`. */
109
+ relayedBlock?: number;
103
110
  relayedAt?: number;
111
+ spentAt?: number;
104
112
  }
105
113
 
106
114
  /** Where a send or withdrawal goes; `self` merges the wallet's own notes and nothing leaves. */
@@ -249,14 +257,20 @@ export function payWith(all: Coin[], need: bigint, fee: bigint, onTheWay: boolea
249
257
  return fail('insufficient-funds', `${need} is more than the balance`);
250
258
  }
251
259
 
252
- /** The smallest public withdrawal that still pays `net` after the protocol's cut. */
260
+ /** The smallest public withdrawal that still pays `net` after the protocol's cut. The cut is a rate of the whole
261
+ * withdrawal, rounded down, and the rate read off one withdrawal (`fee / gross`) is at most its tier's: the smallest
262
+ * gross that pays `net` at that rate is never more than the answer within the tier, so a read or two settles it.
263
+ * ponytail: where a lower tier's bound lies between two reads, that bound may already pay `net`, a little less than
264
+ * the gross this returns; a getter for the schedule's tiers would find it. */
253
265
  export async function grossed(ctx: Ctx, chainId: bigint, tokenId: number, net: bigint): Promise<bigint> {
254
266
  const chain = chainCtx(ctx, chainId).chain;
255
267
  let gross = net;
256
- for (let i = 0; i < 8; i++) {
268
+ // each read moves on by a unit at least, and a few settle each tier a withdrawal crosses
269
+ for (let i = 0; i < 64; i++) {
257
270
  const paid = gross - (await chain.withdrawFee(tokenId, gross));
258
271
  if (paid >= net) return gross;
259
- gross += net - paid;
272
+ if (paid <= 0n) break;
273
+ gross = ((net - 1n) * gross) / paid + 1n;
260
274
  }
261
275
  return fail('unavailable', 'the withdrawal fee schedule does not settle');
262
276
  }
@@ -276,10 +290,16 @@ export async function withdrawalOf(
276
290
  return { withdrawn, protocol: withdrawn - net };
277
291
  }
278
292
 
293
+ /** The rate, in basis points, of a protocol's `cut` out of a withdrawal of `gross`. The cut is rounded down, so this is
294
+ * exact once `gross` is 10,000 base units or more. */
295
+ export const bpsOf = (cut: bigint, gross: bigint): number =>
296
+ gross === 0n ? 0 : Number((cut * 10_000n + gross - 1n) / gross);
297
+
279
298
  /** The most a payment out of `all` pays its recipient, the relayer's `fee` and the protocol's cut on top
280
299
  * (`withdrawalOf`): a withdrawal of what the relayer leaves, less the cut on it, or a private payment of what the
281
300
  * relayer's grossed fee leaves. Coins one JoinSplit cannot spend together (`pick`) are merged first, each merge
282
- * paying the relayer's grossed fee, as a consolidation's quote does. */
301
+ * paying the relayer's grossed fee, as a consolidation's quote does. `fee` is all the fees take of `all`; where they
302
+ * take it all, what they would be with nothing left to pay. */
283
303
  export async function maxPayment(
284
304
  ctx: Ctx,
285
305
  chainId: bigint,
@@ -287,13 +307,16 @@ export async function maxPayment(
287
307
  isPublic: boolean,
288
308
  all: Coin[],
289
309
  fee: bigint,
290
- ): Promise<bigint> {
310
+ ): Promise<{ amount: bigint; fee: bigint; merges: number }> {
291
311
  const whole = all.length < 2 || (all.length === 2 && all.some((c) => c.kind === 'note'));
292
- const merges = whole ? 0n : BigInt(roundsToMerge(all)) * (await grossed(ctx, chainId, tokenId, fee));
293
- const total = all.reduce((s, c) => s + c.value, 0n) - merges;
294
- const left = total - (isPublic ? fee : await grossed(ctx, chainId, tokenId, fee));
295
- if (left <= 0n) return 0n;
296
- return isPublic ? left - (await chainCtx(ctx, chainId).chain.withdrawFee(tokenId, left)) : left;
312
+ const merges = whole ? 0 : roundsToMerge(all);
313
+ const gross = !isPublic || merges > 0 ? await grossed(ctx, chainId, tokenId, fee) : fee;
314
+ const fees = BigInt(merges) * gross + (isPublic ? fee : gross);
315
+ const total = all.reduce((s, c) => s + c.value, 0n);
316
+ const left = total - fees;
317
+ if (left <= 0n) return { amount: 0n, fee: fees, merges };
318
+ const amount = isPublic ? left - (await chainCtx(ctx, chainId).chain.withdrawFee(tokenId, left)) : left;
319
+ return { amount, fee: total - amount, merges };
297
320
  }
298
321
 
299
322
  /** Plans the next step of `op`: a payment's only JoinSplit (or throws why there is none), or a consolidation's next
@@ -628,27 +651,81 @@ const positionsAt = (ctx: Ctx, chainId: bigint, index: number): Position[] =>
628
651
  const positionOf = (ctx: Ctx, chainId: bigint, index: number): Position | undefined =>
629
652
  positionsAt(ctx, chainId, index).find((f) => !f.tentative);
630
653
 
631
- /** Whether the step's first input is spent on chain: among the final nullifiers (as sync last saw them), or among the
632
- * newer ones. Nothing but the step spends a locked input (rage exit refuses one, and a wallet is open in one tab), so
633
- * a spent input is the step landing. Reads the pages everyone polls, never asks about the nullifier. */
634
- async function inputSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): Promise<boolean> {
635
- const f = positionOf(ctx, op.chainId, step.inputs[0].index);
636
- if (f?.spent) return true;
637
- if (!f?.nullifier) return false;
654
+ /** Whether any of the step's inputs is spent on chain: among the final nullifiers (as sync last saw them), or among
655
+ * the newer ones. Reads the pages everyone polls, never asks about a nullifier. */
656
+ async function inputsSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): Promise<boolean> {
657
+ const inputs = step.inputs.map((coin) => positionOf(ctx, op.chainId, coin.index));
658
+ if (inputs.some((f) => f?.spent)) return true;
659
+ // copies: sync alone moves the watches, over final entries
660
+ const watches = inputs.flatMap((f) => (f?.nullifier ? [{ ...f.nullifier }] : []));
661
+ if (watches.length === 0) return false;
638
662
  const c = chainCtx(ctx, op.chainId);
639
663
  const { nullifiers } = await c.indexer.status();
640
- // a copy: sync alone moves the watch, over final entries
641
- const watch = { ...f.nullifier };
642
- const hits = await scan([watch], 8, (p) => c.indexer.page('nullifiers', p), watch.scannedTo, nullifiers);
664
+ const from = Math.min(...watches.map((w) => w.scannedTo));
665
+ const hits = await scan(watches, 8, (p) => c.indexer.page('nullifiers', p), from, nullifiers);
643
666
  return hits.length > 0;
644
667
  }
645
668
 
646
- /** Where a JoinSplit relayed when the indexer had `relayedAt` nullifiers stands, by its first input `f` as sync last
647
- * saw it: spent, or the final nullifiers passed it without its own (a reorg dropped it, or the relayer never sent it),
648
- * or neither yet. */
649
- export function relayedState(f: Position | undefined, relayedAt: number): 'landed' | 'dropped' | 'waiting' {
650
- if (f?.spent) return 'landed';
651
- return f?.nullifier && f.nullifier.scannedTo >= relayedAt ? 'dropped' : 'waiting';
669
+ /** Where a relayed step stands, by its `change` (a merge's note) and its `inputs` as sync last saw them:
670
+ * - `landed`: the change is final where this wallet expects it;
671
+ * - `taken`: an input is spent and the final commitments passed `spentAt` without the change, so another transaction
672
+ * spent it, and this step can never land;
673
+ * - `dropped`: no input is spent and the final nullifiers passed `relayedAt` without them: whatever was spending them
674
+ * is gone, a reorg dropped it or the relayer never sent it;
675
+ * - `mined`: the change is in a block, not final yet;
676
+ * - `waiting`: none of these yet. */
677
+ export function relayedState(
678
+ change: Pick<NoteRecord, 'found' | 'watch'> | undefined,
679
+ inputs: (Position | undefined)[],
680
+ relayedAt: number | undefined,
681
+ spentAt: number | undefined,
682
+ ): 'landed' | 'taken' | 'dropped' | 'mined' | 'waiting' {
683
+ const seen = change?.found.filter((f) => f.confirmed) ?? [];
684
+ if (seen.some((f) => !f.tentative)) return 'landed';
685
+ if (inputs.some((f) => f?.spent)) {
686
+ // a final position not read yet may be the change
687
+ const missed =
688
+ spentAt !== undefined &&
689
+ !!change?.watch &&
690
+ change.watch.scannedTo >= spentAt &&
691
+ !change.found.some((f) => !f.tentative);
692
+ if (missed) return 'taken';
693
+ } else {
694
+ const watches = inputs.flatMap((f) => (f?.nullifier ? [f.nullifier] : []));
695
+ if (relayedAt !== undefined && watches.length > 0 && watches.every((w) => w.scannedTo >= relayedAt))
696
+ return 'dropped';
697
+ }
698
+ return seen.length > 0 ? 'mined' : 'waiting';
699
+ }
700
+
701
+ /** `relayedState` of `step`, failing the operation once another transaction took an input. The first time an input
702
+ * is seen spent, short of the step landing, it notes the final commitment count: a later status counts no fewer, so
703
+ * once the change's watch passes it, it has looked wherever that transaction put its notes. */
704
+ async function standing(
705
+ ctx: Ctx,
706
+ op: SpendOperation,
707
+ step: Step | MergeStep,
708
+ ): Promise<ReturnType<typeof relayedState>> {
709
+ const inputs = step.inputs.map((coin) => positionOf(ctx, op.chainId, coin.index));
710
+ const salt = 'output' in step ? step.output.salt : step.outputs[1].salt;
711
+ const change = ctx.state.notes.find((n) => n.chainId === op.chainId && n.salt === salt);
712
+ const now = relayedState(change, inputs, step.relayedAt, step.spentAt);
713
+ if (now === 'taken')
714
+ fail('spent-elsewhere', 'another device of this wallet spent a note or deposit of it first: it paid nothing');
715
+ if (now !== 'landed' && step.spentAt === undefined && inputs.some((f) => f?.spent)) {
716
+ step.spentAt = (await chainCtx(ctx, op.chainId).indexer.status()).commitmentsFinal;
717
+ await ctx.save();
718
+ }
719
+ return now;
720
+ }
721
+
722
+ /** The indexer's nullifier count once it is past `block`, so past the nullifiers of a transaction mined there unless a
723
+ * reorg moved them; `undefined` until then. The relayer answers once the block is mined, which may be before the
724
+ * indexer has read it. */
725
+ export async function nullifiersPast(indexer: Pick<Indexer, 'status'>, block: number): Promise<number | undefined> {
726
+ if ((await indexer.status()).cursor < block) return undefined;
727
+ // a status reads its counts before its cursor, so the count comes from the next one
728
+ return (await indexer.status()).nullifiers;
652
729
  }
653
730
 
654
731
  /** Whether `op`'s payment or withdrawal is on chain: done, or relayed, which the relayer answers once it is mined. Until
@@ -659,6 +736,18 @@ export function landed(op: SpendOperation): boolean {
659
736
  );
660
737
  }
661
738
 
739
+ /** Whether `op` can still be cancelled, having paid nothing: a payment, withdrawal or consolidation under way with no
740
+ * request out to the relayer, and no merge of it landed. A step at its relay stage has one out, as its request leaves
741
+ * the moment it gets there; so has one built again after a relay of it reverted, as that request may still land: a
742
+ * JoinSplit's `attempts` count only those, and a merge, whose `attempts` count its prover's refusals too, is `sent`. */
743
+ export function cancellable(op: OperationRecord): boolean {
744
+ if (op.status !== 'pending' || op.kind === 'deposit' || op.data.paid > 0n) return false;
745
+ const step = op.data.step;
746
+ if (!step) return true;
747
+ if ('output' in step) return !step.sent && step.stage !== 'proved' && step.stage !== 'relayed';
748
+ return step.attempts === 0 && step.stage !== 'attested' && step.stage !== 'relayed';
749
+ }
750
+
662
751
  function markSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): void {
663
752
  for (const coin of step.inputs) for (const f of positionsAt(ctx, op.chainId, coin.index)) f.spent = true;
664
753
  }
@@ -671,14 +760,17 @@ function placeChange(ctx: Ctx, op: SpendOperation, salt: Hex, index: number | nu
671
760
  }
672
761
  }
673
762
 
674
- /** Takes `op` as far as it can go now, saving before each step that leaves the device. */
763
+ /** Takes `op` as far as it can go now, saving before each step that leaves the device. One cancelled meanwhile
764
+ * (`cancel`) finishes the stage under way and starts no other: its status is looked at after each, and nothing it does
765
+ * later is kept, as it is out of the state. A relayer that is relaying its request already throws `Relaying`. */
675
766
  export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void> {
676
767
  const data = op.data;
677
768
  const c = chainCtx(ctx, op.chainId);
678
769
  for (;;) {
770
+ if (op.status !== 'pending') return;
679
771
  if (!data.step) {
680
772
  const step = await planStep(ctx, op);
681
- if (!step) return;
773
+ if (!step || op.status !== 'pending') return;
682
774
  if (step === 'done') {
683
775
  op.status = 'done';
684
776
  await ctx.save();
@@ -697,6 +789,7 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
697
789
  step.stage = 'noted';
698
790
  await ctx.save();
699
791
  }
792
+ if (op.status !== 'pending') return;
700
793
  if (step.stage === 'noted') {
701
794
  const built = await witnessOf(ctx, op, step);
702
795
  if (!built) return;
@@ -742,6 +835,7 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
742
835
  step.stage = 'proved';
743
836
  await ctx.save();
744
837
  }
838
+ if (op.status !== 'pending') return;
745
839
  if (step.stage === 'proved') {
746
840
  const attestation = await attest(c.config, step.statement!, step.witness!);
747
841
  step.request = { ...step.request!, attestation };
@@ -749,21 +843,25 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
749
843
  step.stage = 'attested';
750
844
  await ctx.save();
751
845
  }
846
+ if (op.status !== 'pending') return;
847
+ if (step.stage === 'attested' && step.relayedAt !== undefined) {
848
+ // a relay of it reverted on an input spent already: an earlier relay of it landed, one whose answer was lost, or
849
+ // another transaction spent the input. Relaying it again would tell nothing more
850
+ const now = await standing(ctx, op, step);
851
+ if (now === 'waiting') return;
852
+ if (now === 'dropped') delete step.relayedAt;
853
+ else step.stage = 'relayed';
854
+ await ctx.save();
855
+ }
752
856
  if (step.stage === 'attested') {
753
857
  const relayerOut = step.outputs.find((o) => o.role === 'relayer');
754
- let result: Relayed | { reverted: string };
755
- try {
756
- result = await relay(
757
- c.config,
758
- step.request!,
759
- relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined,
760
- );
761
- } catch (e) {
762
- if (e instanceof RelayUnknown) return; // try the same request again next time
763
- throw e;
764
- }
858
+ const result = await relay(
859
+ c.config,
860
+ step.request!,
861
+ relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined,
862
+ );
765
863
  if ('reverted' in result) {
766
- if (!(await inputSpent(ctx, op, step))) {
864
+ if (!(await inputsSpent(ctx, op, step))) {
767
865
  // stale roots, most likely: prove again with the same outputs, next sync. A JoinSplit that keeps reverting
768
866
  // is not on chain and cannot land, so failing it frees its inputs.
769
867
  if (++step.attempts > 3)
@@ -775,20 +873,24 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
775
873
  await ctx.save();
776
874
  return;
777
875
  }
778
- // an earlier relay of it landed, one whose answer was lost
779
- } else {
780
- placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
781
- data.txHashes = [...(data.txHashes ?? []), result.txHash];
876
+ // the indexer counts the nullifiers of whatever spent it already
877
+ step.relayedAt = (await c.indexer.status()).nullifiers;
878
+ await ctx.save();
879
+ return;
782
880
  }
783
- step.relayedAt = (await c.indexer.status()).nullifiers;
881
+ placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
882
+ data.txHashes = [...(data.txHashes ?? []), result.txHash];
883
+ step.relayedBlock = result.block;
784
884
  step.stage = 'relayed';
785
885
  await ctx.save();
786
886
  }
787
887
  if (step.stage === 'relayed') {
788
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt!);
888
+ step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
889
+ const now = await standing(ctx, op, step);
789
890
  if (now === 'dropped') {
790
891
  // relay it again next sync; if it did land after all, that reverts and finds it
791
892
  step.stage = 'attested';
893
+ delete step.relayedAt;
792
894
  await ctx.save();
793
895
  }
794
896
  if (now !== 'landed') return;
@@ -821,6 +923,7 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
821
923
  step.stage = 'noted';
822
924
  await ctx.save();
823
925
  }
926
+ if (op.status !== 'pending') return false;
824
927
  if (step.stage === 'noted') {
825
928
  const built = await mergeWitnessOf(ctx, op, step);
826
929
  if (!built) return false;
@@ -856,6 +959,7 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
856
959
  step.stage = 'attested';
857
960
  await ctx.save();
858
961
  }
962
+ if (op.status !== 'pending') return false;
859
963
  if (step.stage === 'attested') {
860
964
  await requestProof(c.config, prover, step.statement!, step.witness!, step.inputs.length);
861
965
  delete step.witness; // the prover has it: the inputs' openings need not stay saved here
@@ -877,30 +981,39 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
877
981
  step.stage = 'proved';
878
982
  await ctx.save();
879
983
  }
984
+ if (op.status !== 'pending') return false;
985
+ if (step.stage === 'proved' && step.relayedAt !== undefined) {
986
+ // as a JoinSplit's, told apart by its note
987
+ const now = await standing(ctx, op, step);
988
+ if (now === 'waiting') return false;
989
+ if (now === 'dropped') delete step.relayedAt;
990
+ else step.stage = 'relayed';
991
+ await ctx.save();
992
+ }
880
993
  if (step.stage === 'proved') {
881
- let result: Awaited<ReturnType<typeof relayMerge>>;
882
- try {
883
- result = await relayMerge(c.config, step.request!);
884
- } catch (e) {
885
- if (e instanceof RelayUnknown) return false; // try the same request again next time
886
- throw e;
887
- }
994
+ const result = await relayMerge(c.config, step.request!);
888
995
  if ('reverted' in result) {
889
996
  // stale roots, most likely: built again with the same output, next sync
890
- if (!(await inputSpent(ctx, op, step))) return again(result.reverted);
891
- // an earlier relay of it landed, one whose answer was lost
892
- } else {
893
- placeChange(ctx, op, step.output.salt, result.noteIndex);
894
- data.txHashes = [...(data.txHashes ?? []), result.txHash];
997
+ if (!(await inputsSpent(ctx, op, step))) {
998
+ step.sent = true;
999
+ return again(result.reverted);
1000
+ }
1001
+ step.relayedAt = (await c.indexer.status()).nullifiers;
1002
+ await ctx.save();
1003
+ return false;
895
1004
  }
896
- step.relayedAt = (await c.indexer.status()).nullifiers;
1005
+ placeChange(ctx, op, step.output.salt, result.noteIndex);
1006
+ data.txHashes = [...(data.txHashes ?? []), result.txHash];
1007
+ step.relayedBlock = result.block;
897
1008
  step.stage = 'relayed';
898
1009
  await ctx.save();
899
1010
  }
900
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt!);
1011
+ step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
1012
+ const now = await standing(ctx, op, step);
901
1013
  if (now === 'dropped') {
902
1014
  // relay it again next sync; if it did land after all, that reverts and finds it
903
1015
  step.stage = 'proved';
1016
+ delete step.relayedAt;
904
1017
  await ctx.save();
905
1018
  }
906
1019
  if (now !== 'landed') return false;
package/src/state.ts CHANGED
@@ -86,6 +86,10 @@ export interface DepositRecord {
86
86
  /** Chosen by a payer, who told it in storage (a burn), as claimed: its salt is the payer's, with no index
87
87
  * (`burnIndex` -1), and it is never handed out. */
88
88
  payer?: Hex;
89
+ /** Recorded by another device of the wallet, or before a restore: taken as handed out, so never handed out here. A
90
+ * flag of its own rather than the token's `burnNext`, which would also pass the one this device recorded ahead and
91
+ * still holds: with two devices open, each would then record one more ahead each sync, without end. */
92
+ elsewhere?: true;
89
93
  /** The transfer's tag under each token. */
90
94
  watches: { tokenId: number; watch: Watch }[];
91
95
  found: (Position & {
@@ -114,9 +118,27 @@ export interface SentRecord extends Pick<
114
118
  * `unwritten` until storage holds it: every sync tries again. */
115
119
  export type ReceiptRecord = { chainId: bigint; tokenId: number; burnAddress: Hex; atMs: number; unwritten?: true } & (
116
120
  | { kind: 'returned'; index: number; recipient: Hex; amount: bigint; txHash: Hex }
117
- | { kind: 'deposit-address'; depositAddress: Hex }
121
+ | {
122
+ kind: 'deposit-address';
123
+ depositAddress: Hex;
124
+ /** From the relayer's answer, or as storage told them. Absent where storage's record does not tell them, and then
125
+ * only the relayer flushes it. */
126
+ args?: DepositAddressArgs;
127
+ }
118
128
  );
119
129
 
130
+ /** What a deposit address was registered with besides its token and burn address: with those, all a flush of it takes.
131
+ * A wallet's refunds go where a signer of its own signs for, so its refund address is zero. */
132
+ export interface DepositAddressArgs {
133
+ fee: bigint;
134
+ feeRecipient: Hex;
135
+ refundSigner: Hex;
136
+ referrer: Hex;
137
+ referralBps: number;
138
+ }
139
+
140
+ export type DepositAddressReceipt = Extract<ReceiptRecord, { kind: 'deposit-address' }>;
141
+
120
142
  /** A sample of the indexer's final counts, to bound where a note announced at some time can be. */
121
143
  export interface Sample {
122
144
  atMs: number;
@@ -155,6 +177,8 @@ export interface DepositOperation extends OperationBase {
155
177
  burnAddress: Hex;
156
178
  /** Whose burn address it is, when not this wallet's (`deposit({ to })`). */
157
179
  to?: Hex;
180
+ /** The receive code it paid, when `to` was one. */
181
+ code?: string;
158
182
  /** Its transaction, once sent. */
159
183
  txHash?: Hex;
160
184
  };
package/src/sync.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import {
5
5
  type Deposit,
6
6
  type LaneRecord,
7
+ type DepositArgs,
7
8
  type NewRecord,
8
9
  type Note,
9
10
  type OwnReceipt,
@@ -18,11 +19,11 @@ import {
18
19
  } from '@zerc20/storage-sdk';
19
20
  import { type Hex, bytesToHex, hexToBytes, numberToHex, parseAbiItem } from 'viem';
20
21
  import type { Chain, Token } from './chain.js';
21
- import { type ChainCtx, type Ctx, chainCtx, chainState, owner } from './context.js';
22
+ import { type ChainCtx, type Ctx, chainCtx, chainState, owner, refundSigner } from './context.js';
22
23
  import { burnAddress, core } from './core.js';
23
24
  import { type Hit, type Watch, scan } from './scan.js';
24
25
  import { type Asp, type AspStatus, type Indexer, PAGE } from './servers.js';
25
- import type { DepositRecord, NoteReceipt, NoteRecord, Position, Sample } from './state.js';
26
+ import type { DepositAddressArgs, DepositRecord, NoteReceipt, NoteRecord, Position, Sample } from './state.js';
26
27
 
27
28
  /** The ledger's log of each commitment, found by its index (`contracts/src/CommitmentLedger.sol`). */
28
29
  const committedEvent = parseAbiItem('event Committed(uint64 indexed index, uint256[4] fields)');
@@ -95,7 +96,12 @@ async function writeReceipts(ctx: Ctx, tokens: Map<bigint, number[]>): Promise<v
95
96
  amount: hexToBytes(numberToHex(r.amount, { size: 32 })),
96
97
  txHash: hexToBytes(r.txHash),
97
98
  }
98
- : { ...head, kind: r.kind, depositAddress: hexToBytes(r.depositAddress) };
99
+ : {
100
+ ...head,
101
+ kind: r.kind,
102
+ depositAddress: hexToBytes(r.depositAddress),
103
+ ...(r.args && { args: depositArgsBytes(r.args) }),
104
+ };
99
105
  takeRefresh(
100
106
  ctx,
101
107
  await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planReceipt(ctx.view, s, receipt)),
@@ -106,6 +112,24 @@ async function writeReceipts(ctx: Ctx, tokens: Map<bigint, number[]>): Promise<v
106
112
  }
107
113
  }
108
114
 
115
+ /** A deposit address's arguments as the wallet keeps them, from storage's. */
116
+ const depositArgs = (a: DepositArgs): DepositAddressArgs => ({
117
+ fee: big(a.fee),
118
+ feeRecipient: bytesToHex(a.feeRecipient),
119
+ refundSigner: bytesToHex(a.refundSigner),
120
+ referrer: bytesToHex(a.referrer),
121
+ referralBps: a.referralBps,
122
+ });
123
+
124
+ /** And storage's, from the wallet's. */
125
+ const depositArgsBytes = (a: DepositAddressArgs): DepositArgs => ({
126
+ fee: hexToBytes(numberToHex(a.fee, { size: 32 })),
127
+ feeRecipient: hexToBytes(a.feeRecipient),
128
+ refundSigner: hexToBytes(a.refundSigner),
129
+ referrer: hexToBytes(a.referrer),
130
+ referralBps: a.referralBps,
131
+ });
132
+
109
133
  /** A storage receipt as the wallet keeps it. */
110
134
  export const noteReceipt = (r: Receipt): NoteReceipt => ({
111
135
  kind: r.kind,
@@ -182,16 +206,24 @@ function takeSent(ctx: Ctx, d: Deposit, to: Uint8Array, tokens: Map<bigint, numb
182
206
  function takeReceipt(ctx: Ctx, r: OwnReceipt & { atNs: bigint }): void {
183
207
  const burnAddress = bytesToHex(r.burnAddress);
184
208
  const atMs = Number(r.atNs / 1_000_000n);
209
+ // a return is told apart by its deposit, a deposit address by itself: one burn address can have more than one, each
210
+ // registered with arguments of its own
185
211
  const same = ctx.state.receipts.find(
186
212
  (x) =>
187
213
  x.chainId === r.chainId &&
188
- x.kind === r.kind &&
189
214
  x.burnAddress === burnAddress &&
190
- (x.kind !== 'returned' || (r.kind === 'returned' && x.index === r.index)),
215
+ (x.kind === 'returned' && r.kind === 'returned'
216
+ ? x.index === r.index
217
+ : x.kind === 'deposit-address' &&
218
+ r.kind === 'deposit-address' &&
219
+ x.depositAddress.toLowerCase() === bytesToHex(r.depositAddress)),
191
220
  );
221
+ // what its address was registered with, where the record tells it
222
+ const args = r.kind === 'deposit-address' && r.args && { args: depositArgs(r.args) };
192
223
  if (same) {
193
224
  delete same.unwritten;
194
225
  same.atMs = atMs;
226
+ Object.assign(same, args);
195
227
  return;
196
228
  }
197
229
  const head = { chainId: r.chainId, tokenId: r.tokenId, burnAddress, atMs };
@@ -205,7 +237,7 @@ function takeReceipt(ctx: Ctx, r: OwnReceipt & { atNs: bigint }): void {
205
237
  amount: big(r.amount),
206
238
  txHash: bytesToHex(r.txHash),
207
239
  }
208
- : { ...head, kind: r.kind, depositAddress: bytesToHex(r.depositAddress) },
240
+ : { ...head, kind: r.kind, depositAddress: bytesToHex(r.depositAddress), ...args },
209
241
  );
210
242
  }
211
243
 
@@ -255,10 +287,6 @@ function takeBurnBlock(
255
287
  const salt = hex32(bytes);
256
288
  const address = core.burnAddressOf(b.chainId, publicKey, salt) as Hex | undefined;
257
289
  if (!address || ctx.state.deposits.some((d) => d.chainId === b.chainId && d.salt === salt)) return;
258
- if (!pos) {
259
- const next = chainState(ctx, b.chainId).burnNext;
260
- next[b.tokenId] = Math.max(next[b.tokenId] ?? 0, b.first + i + 1);
261
- }
262
290
  const watch = { key: numberToHex(core.transferTag(b.tokenId, address), { size: 4 }), from, scannedTo: from };
263
291
  const record: DepositRecord = {
264
292
  chainId: b.chainId,
@@ -272,6 +300,7 @@ function takeBurnBlock(
272
300
  found: [],
273
301
  };
274
302
  if (pos) record.managed = true;
303
+ else record.elsewhere = true;
275
304
  ctx.state.deposits.push(record);
276
305
  });
277
306
  }
@@ -281,7 +310,8 @@ export function unusedBurn(ctx: Ctx, chainId: bigint, tokenId: number): DepositR
281
310
  const next = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
282
311
  let best: DepositRecord | undefined;
283
312
  for (const d of ctx.state.deposits) {
284
- if (d.chainId !== chainId || d.tokenId !== tokenId || d.managed || d.payer || d.burnIndex < next) continue;
313
+ if (d.chainId !== chainId || d.tokenId !== tokenId || d.managed || d.payer || d.elsewhere || d.burnIndex < next)
314
+ continue;
285
315
  if (!best || d.burnIndex < best.burnIndex) best = d;
286
316
  }
287
317
  return best;
@@ -302,17 +332,18 @@ export const nextBurn = (ctx: Ctx, chainId: bigint, tokenId: number): number =>
302
332
  unusedBurn(ctx, chainId, tokenId)?.burnIndex ?? toRecord(ctx, chainId, tokenId);
303
333
 
304
334
  /** Records this wallet's next burn address of a token in storage (a burn block of one), then watches it. Every device
305
- * of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes; needs no seed. */
335
+ * of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes, `urgent` unless
336
+ * it is one made ahead; needs no seed. */
306
337
  export async function recordBurn(
307
338
  ctx: Ctx,
308
339
  chainId: bigint,
309
340
  tokenId: number,
310
341
  tokens: Map<bigint, number[]>,
311
- signal?: AbortSignal,
342
+ opts: { signal?: AbortSignal; urgent?: boolean } = {},
312
343
  ): Promise<DepositRecord> {
313
344
  const index = toRecord(ctx, chainId, tokenId);
314
345
  const key = burnKey(ctx.view.seed);
315
- const { salt, address } = await burnAddress(key, chainId, tokenId, index, owner(ctx)!.publicKey, signal);
346
+ const { salt, address } = await burnAddress(key, chainId, tokenId, index, owner(ctx)!.publicKey, opts);
316
347
  const { commitmentsFinal: from } = await chainCtx(ctx, chainId).indexer.status();
317
348
  const block = { chainId, tokenId, first: index, salts: [hexToBytes(salt)] };
318
349
  const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planBurnBlock(ctx.view, s, block));
@@ -350,7 +381,7 @@ export async function topUpBurns(ctx: Ctx, signal?: AbortSignal): Promise<void>
350
381
  let tokens: Map<bigint, number[]> | undefined;
351
382
  for (const { chainId, tokenId } of used.values()) {
352
383
  if (!unusedBurn(ctx, chainId, tokenId))
353
- await recordBurn(ctx, chainId, tokenId, (tokens ??= await tokenIds(ctx)), signal);
384
+ await recordBurn(ctx, chainId, tokenId, (tokens ??= await tokenIds(ctx)), { signal, urgent: false });
354
385
  }
355
386
  }
356
387
 
@@ -394,12 +425,13 @@ function upsertNote(ctx: Ctx, a: NewRecord, note: Note, tokens: Map<bigint, numb
394
425
  });
395
426
  }
396
427
 
397
- /** Receive codes of each token whose keys are derived while the seed is held, to hand out while it is not. */
428
+ /** Receive codes of each token whose keys are derived while the seed is held, to hand out while it is not, and as many
429
+ * deposit addresses' refund signers. */
398
430
  export const CODES_AHEAD = 4;
399
431
 
400
432
  /** With the seed held, each owner not derived yet: the wallet's own, each of its burn addresses' (a receive code each),
401
- * the next `CODES_AHEAD` codes' of each of `tokens`, and each note's key's; and the watch of each note that waited for
402
- * its key. Without it, the watch of each note whose key was derived before. */
433
+ * the next `CODES_AHEAD` codes' of each of `tokens` with their deposit addresses' refund signers, and each note's key's;
434
+ * and the watch of each note that waited for its key. Without it, the watch of each note whose key was derived before. */
403
435
  export function keyAll(ctx: Ctx, tokens: Token[]): void {
404
436
  owner(ctx);
405
437
  // ponytail: one derivation per own burn address as the wallet opens; keep owners in the saved state if wallets grow
@@ -409,7 +441,10 @@ export function keyAll(ctx: Ctx, tokens: Token[]): void {
409
441
  }
410
442
  for (const { chainId, tokenId } of tokens) {
411
443
  const next = nextBurn(ctx, chainId, tokenId);
412
- for (let index = next; index < next + CODES_AHEAD; index++) owner(ctx, { chainId, tokenId, index });
444
+ for (let index = next; index < next + CODES_AHEAD; index++) {
445
+ owner(ctx, { chainId, tokenId, index });
446
+ refundSigner(ctx, { chainId, tokenId, index });
447
+ }
413
448
  }
414
449
  for (const n of ctx.state.notes) {
415
450
  if (n.keyIndex === undefined) continue;