@zerc20/wallet-sdk 0.5.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.
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 {
@@ -27,16 +30,7 @@ import { fail } from './errors.js';
27
30
  import { prove } from './prove.js';
28
31
  import { scan } from './scan.js';
29
32
  import type { Indexer } from './servers.js';
30
- import {
31
- type Relayed,
32
- RelayUnknown,
33
- attest,
34
- proofOf,
35
- relay,
36
- relayMerge,
37
- relayerFees,
38
- requestProof,
39
- } from './services.js';
33
+ import { attest, proofOf, relay, relayMerge, relayerFees, requestProof } from './services.js';
40
34
  import { type NoteRecord, type OperationRecord, type Position, type SpendOperation, unspent } from './state.js';
41
35
  import { commitmentsAt, noteReceipt, storageState, takeRefresh, tokenIds } from './sync.js';
42
36
  import { aspPath, aspPaths, commitmentPath, commitmentPaths } from './tree.js';
@@ -77,9 +71,14 @@ export interface Step {
77
71
  attempts: number;
78
72
  /** Once relayed: the block the relayer saw it mined in. */
79
73
  relayedBlock?: number;
80
- /** The indexer's nullifier count once it is past that block, or just after a relay that found it landed already.
81
- * Final nullifiers past it, and the input's still not among them, means a reorg dropped the transaction. */
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. */
82
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;
83
82
  }
84
83
 
85
84
  /** The most inputs one merge spends: the attester takes requests up to 2 MB, at 8 KiB of sealed witness per input
@@ -103,9 +102,13 @@ export interface MergeStep {
103
102
  /** Refused by the prover, or reverting with no sign of it on chain: built again with the same output a few times,
104
103
  * then failed. */
105
104
  attempts: number;
106
- /** As a JoinSplit's. */
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`. */
107
109
  relayedBlock?: number;
108
110
  relayedAt?: number;
111
+ spentAt?: number;
109
112
  }
110
113
 
111
114
  /** Where a send or withdrawal goes; `self` merges the wallet's own notes and nothing leaves. */
@@ -648,27 +651,72 @@ const positionsAt = (ctx: Ctx, chainId: bigint, index: number): Position[] =>
648
651
  const positionOf = (ctx: Ctx, chainId: bigint, index: number): Position | undefined =>
649
652
  positionsAt(ctx, chainId, index).find((f) => !f.tentative);
650
653
 
651
- /** Whether the step's first input is spent on chain: among the final nullifiers (as sync last saw them), or among the
652
- * newer ones. Nothing but the step spends a locked input (rage exit refuses one, and a wallet is open in one tab), so
653
- * a spent input is the step landing. Reads the pages everyone polls, never asks about the nullifier. */
654
- async function inputSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): Promise<boolean> {
655
- const f = positionOf(ctx, op.chainId, step.inputs[0].index);
656
- if (f?.spent) return true;
657
- 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;
658
662
  const c = chainCtx(ctx, op.chainId);
659
663
  const { nullifiers } = await c.indexer.status();
660
- // a copy: sync alone moves the watch, over final entries
661
- const watch = { ...f.nullifier };
662
- 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);
663
666
  return hits.length > 0;
664
667
  }
665
668
 
666
- /** Where a relayed JoinSplit stands, by its first input `f` as sync last saw it and `relayedAt`, a nullifier count past
667
- * its own (`undefined` while not known): spent, or the final nullifiers passed that count without its own (a reorg
668
- * dropped it, or the relayer never sent it), or neither yet. */
669
- export function relayedState(f: Position | undefined, relayedAt: number | undefined): 'landed' | 'dropped' | 'waiting' {
670
- if (f?.spent) return 'landed';
671
- return f?.nullifier && relayedAt !== undefined && 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;
672
720
  }
673
721
 
674
722
  /** The indexer's nullifier count once it is past `block`, so past the nullifiers of a transaction mined there unless a
@@ -688,6 +736,18 @@ export function landed(op: SpendOperation): boolean {
688
736
  );
689
737
  }
690
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
+
691
751
  function markSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): void {
692
752
  for (const coin of step.inputs) for (const f of positionsAt(ctx, op.chainId, coin.index)) f.spent = true;
693
753
  }
@@ -700,14 +760,17 @@ function placeChange(ctx: Ctx, op: SpendOperation, salt: Hex, index: number | nu
700
760
  }
701
761
  }
702
762
 
703
- /** 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`. */
704
766
  export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void> {
705
767
  const data = op.data;
706
768
  const c = chainCtx(ctx, op.chainId);
707
769
  for (;;) {
770
+ if (op.status !== 'pending') return;
708
771
  if (!data.step) {
709
772
  const step = await planStep(ctx, op);
710
- if (!step) return;
773
+ if (!step || op.status !== 'pending') return;
711
774
  if (step === 'done') {
712
775
  op.status = 'done';
713
776
  await ctx.save();
@@ -726,6 +789,7 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
726
789
  step.stage = 'noted';
727
790
  await ctx.save();
728
791
  }
792
+ if (op.status !== 'pending') return;
729
793
  if (step.stage === 'noted') {
730
794
  const built = await witnessOf(ctx, op, step);
731
795
  if (!built) return;
@@ -771,6 +835,7 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
771
835
  step.stage = 'proved';
772
836
  await ctx.save();
773
837
  }
838
+ if (op.status !== 'pending') return;
774
839
  if (step.stage === 'proved') {
775
840
  const attestation = await attest(c.config, step.statement!, step.witness!);
776
841
  step.request = { ...step.request!, attestation };
@@ -778,21 +843,25 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
778
843
  step.stage = 'attested';
779
844
  await ctx.save();
780
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
+ }
781
856
  if (step.stage === 'attested') {
782
857
  const relayerOut = step.outputs.find((o) => o.role === 'relayer');
783
- let result: Relayed | { reverted: string };
784
- try {
785
- result = await relay(
786
- c.config,
787
- step.request!,
788
- relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined,
789
- );
790
- } catch (e) {
791
- if (e instanceof RelayUnknown) return; // try the same request again next time
792
- throw e;
793
- }
858
+ const result = await relay(
859
+ c.config,
860
+ step.request!,
861
+ relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined,
862
+ );
794
863
  if ('reverted' in result) {
795
- if (!(await inputSpent(ctx, op, step))) {
864
+ if (!(await inputsSpent(ctx, op, step))) {
796
865
  // stale roots, most likely: prove again with the same outputs, next sync. A JoinSplit that keeps reverting
797
866
  // is not on chain and cannot land, so failing it frees its inputs.
798
867
  if (++step.attempts > 3)
@@ -804,23 +873,24 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
804
873
  await ctx.save();
805
874
  return;
806
875
  }
807
- // an earlier relay of it landed, one whose answer was lost, and the indexer counts its nullifier already
876
+ // the indexer counts the nullifiers of whatever spent it already
808
877
  step.relayedAt = (await c.indexer.status()).nullifiers;
809
- } else {
810
- placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
811
- data.txHashes = [...(data.txHashes ?? []), result.txHash];
812
- step.relayedBlock = result.block;
813
- delete step.relayedAt;
878
+ await ctx.save();
879
+ return;
814
880
  }
881
+ placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
882
+ data.txHashes = [...(data.txHashes ?? []), result.txHash];
883
+ step.relayedBlock = result.block;
815
884
  step.stage = 'relayed';
816
885
  await ctx.save();
817
886
  }
818
887
  if (step.stage === 'relayed') {
819
888
  step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
820
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt);
889
+ const now = await standing(ctx, op, step);
821
890
  if (now === 'dropped') {
822
891
  // relay it again next sync; if it did land after all, that reverts and finds it
823
892
  step.stage = 'attested';
893
+ delete step.relayedAt;
824
894
  await ctx.save();
825
895
  }
826
896
  if (now !== 'landed') return;
@@ -853,6 +923,7 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
853
923
  step.stage = 'noted';
854
924
  await ctx.save();
855
925
  }
926
+ if (op.status !== 'pending') return false;
856
927
  if (step.stage === 'noted') {
857
928
  const built = await mergeWitnessOf(ctx, op, step);
858
929
  if (!built) return false;
@@ -888,6 +959,7 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
888
959
  step.stage = 'attested';
889
960
  await ctx.save();
890
961
  }
962
+ if (op.status !== 'pending') return false;
891
963
  if (step.stage === 'attested') {
892
964
  await requestProof(c.config, prover, step.statement!, step.witness!, step.inputs.length);
893
965
  delete step.witness; // the prover has it: the inputs' openings need not stay saved here
@@ -909,33 +981,39 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
909
981
  step.stage = 'proved';
910
982
  await ctx.save();
911
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
+ }
912
993
  if (step.stage === 'proved') {
913
- let result: Awaited<ReturnType<typeof relayMerge>>;
914
- try {
915
- result = await relayMerge(c.config, step.request!);
916
- } catch (e) {
917
- if (e instanceof RelayUnknown) return false; // try the same request again next time
918
- throw e;
919
- }
994
+ const result = await relayMerge(c.config, step.request!);
920
995
  if ('reverted' in result) {
921
996
  // stale roots, most likely: built again with the same output, next sync
922
- if (!(await inputSpent(ctx, op, step))) return again(result.reverted);
923
- // an earlier relay of it landed, one whose answer was lost, and the indexer counts its nullifiers already
997
+ if (!(await inputsSpent(ctx, op, step))) {
998
+ step.sent = true;
999
+ return again(result.reverted);
1000
+ }
924
1001
  step.relayedAt = (await c.indexer.status()).nullifiers;
925
- } else {
926
- placeChange(ctx, op, step.output.salt, result.noteIndex);
927
- data.txHashes = [...(data.txHashes ?? []), result.txHash];
928
- step.relayedBlock = result.block;
929
- delete step.relayedAt;
1002
+ await ctx.save();
1003
+ return false;
930
1004
  }
1005
+ placeChange(ctx, op, step.output.salt, result.noteIndex);
1006
+ data.txHashes = [...(data.txHashes ?? []), result.txHash];
1007
+ step.relayedBlock = result.block;
931
1008
  step.stage = 'relayed';
932
1009
  await ctx.save();
933
1010
  }
934
1011
  step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
935
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt);
1012
+ const now = await standing(ctx, op, step);
936
1013
  if (now === 'dropped') {
937
1014
  // relay it again next sync; if it did land after all, that reverts and finds it
938
1015
  step.stage = 'proved';
1016
+ delete step.relayedAt;
939
1017
  await ctx.save();
940
1018
  }
941
1019
  if (now !== 'landed') return false;