@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/dist/spend.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type Recipient } from '@zerc20/storage-sdk';
2
2
  import { type Address, type Hex } from 'viem';
3
3
  import { type Ctx } from './context.js';
4
- import { type OperationRecord, type Position, type SpendOperation } from './state.js';
4
+ import type { Indexer } from './servers.js';
5
+ import { type NoteRecord, type OperationRecord, type Position, type SpendOperation } from './state.js';
5
6
  /** Something spendable. */
6
7
  export interface Coin {
7
8
  kind: 'note' | 'burn';
@@ -38,9 +39,16 @@ export interface Step {
38
39
  request?: object;
39
40
  /** Reverts with no sign of it on chain: proved again with the same outputs a few times, then failed. */
40
41
  attempts: number;
41
- /** Once relayed: the indexer's nullifier count just after. Final nullifiers past it, and the input's still not among
42
- * them, means a reorg dropped the transaction. */
42
+ /** Once relayed: the block the relayer saw it mined in. */
43
+ relayedBlock?: number;
44
+ /** The indexer's nullifier count once it is past that block, or just after a relay that reverted on an input spent
45
+ * already: that leaves the step `attested`, not relayed again until its change tells whose transaction spent it.
46
+ * Final nullifiers past it, and its inputs still not among them, means whatever spent them is gone: a reorg dropped
47
+ * it, or the relayer never sent it. */
43
48
  relayedAt?: number;
49
+ /** The final commitment count once an input is seen spent. Final commitments past it, and the change still not among
50
+ * them, means another transaction spent the input, and this step can never land. */
51
+ spentAt?: number;
44
52
  }
45
53
  /** The most inputs one merge spends: the attester takes requests up to 2 MB, at 8 KiB of sealed witness per input
46
54
  * sent as hex, and the prover folds them in one job. */
@@ -62,7 +70,13 @@ export interface MergeStep {
62
70
  /** Refused by the prover, or reverting with no sign of it on chain: built again with the same output a few times,
63
71
  * then failed. */
64
72
  attempts: number;
73
+ /** Built again after a relay of it reverted, which counts in `attempts` beside the prover's refusals: the request
74
+ * out may still land, so the step is not cancelled. */
75
+ sent?: true;
76
+ /** As a JoinSplit's, with `proved` where a JoinSplit is `attested`. */
77
+ relayedBlock?: number;
65
78
  relayedAt?: number;
79
+ spentAt?: number;
66
80
  }
67
81
  /** Where a send or withdrawal goes; `self` merges the wallet's own notes and nothing leaves. */
68
82
  export type Destination = {
@@ -116,7 +130,11 @@ export declare function mergePool(all: Coin[], chosen: number[] | undefined, cha
116
130
  * into one note first would pay it, coins `onTheWay` might, or nothing will. A payment never waits or merges itself:
117
131
  * that is the app's call. */
118
132
  export declare function payWith(all: Coin[], need: bigint, fee: bigint, onTheWay: boolean): Coin[];
119
- /** The smallest public withdrawal that still pays `net` after the protocol's cut. */
133
+ /** The smallest public withdrawal that still pays `net` after the protocol's cut. The cut is a rate of the whole
134
+ * withdrawal, rounded down, and the rate read off one withdrawal (`fee / gross`) is at most its tier's: the smallest
135
+ * gross that pays `net` at that rate is never more than the answer within the tier, so a read or two settles it.
136
+ * ponytail: where a lower tier's bound lies between two reads, that bound may already pay `net`, a little less than
137
+ * the gross this returns; a getter for the schedule's tiers would find it. */
120
138
  export declare function grossed(ctx: Ctx, chainId: bigint, tokenId: number, net: bigint): Promise<bigint>;
121
139
  /** A payment's one public withdrawal, and the protocol's cut of it, which the payer pays on top: a withdrawal's pays
122
140
  * its recipient `amount` (and the relayer by a note), a private payment's pays the relayer its `fee`. */
@@ -124,11 +142,19 @@ export declare function withdrawalOf(ctx: Ctx, chainId: bigint, tokenId: number,
124
142
  withdrawn: bigint;
125
143
  protocol: bigint;
126
144
  }>;
145
+ /** The rate, in basis points, of a protocol's `cut` out of a withdrawal of `gross`. The cut is rounded down, so this is
146
+ * exact once `gross` is 10,000 base units or more. */
147
+ export declare const bpsOf: (cut: bigint, gross: bigint) => number;
127
148
  /** The most a payment out of `all` pays its recipient, the relayer's `fee` and the protocol's cut on top
128
149
  * (`withdrawalOf`): a withdrawal of what the relayer leaves, less the cut on it, or a private payment of what the
129
150
  * relayer's grossed fee leaves. Coins one JoinSplit cannot spend together (`pick`) are merged first, each merge
130
- * paying the relayer's grossed fee, as a consolidation's quote does. */
131
- export declare function maxPayment(ctx: Ctx, chainId: bigint, tokenId: number, isPublic: boolean, all: Coin[], fee: bigint): Promise<bigint>;
151
+ * paying the relayer's grossed fee, as a consolidation's quote does. `fee` is all the fees take of `all`; where they
152
+ * take it all, what they would be with nothing left to pay. */
153
+ export declare function maxPayment(ctx: Ctx, chainId: bigint, tokenId: number, isPublic: boolean, all: Coin[], fee: bigint): Promise<{
154
+ amount: bigint;
155
+ fee: bigint;
156
+ merges: number;
157
+ }>;
132
158
  /** Plans the next step of `op`: a payment's only JoinSplit (or throws why there is none), or a consolidation's next
133
159
  * merge. `null`: wait for the last merge's note to become spendable. `'done'`: nothing left worth merging. */
134
160
  export declare function planStep(ctx: Ctx, op: SpendOperation): Promise<Step | MergeStep | null | 'done'>;
@@ -140,12 +166,28 @@ export declare const codeOf: (code: string) => import("@zerc20/storage-sdk").Rec
140
166
  /** What `step` paid besides `op`'s amount, the relayer's fee and the protocol's cut: what its inputs hold beyond the
141
167
  * amount and the change. */
142
168
  export declare const feeOf: (op: SpendOperation, step: Step) => bigint;
143
- /** Where a JoinSplit relayed when the indexer had `relayedAt` nullifiers stands, by its first input `f` as sync last
144
- * saw it: spent, or the final nullifiers passed it without its own (a reorg dropped it, or the relayer never sent it),
145
- * or neither yet. */
146
- export declare function relayedState(f: Position | undefined, relayedAt: number): 'landed' | 'dropped' | 'waiting';
169
+ /** Where a relayed step stands, by its `change` (a merge's note) and its `inputs` as sync last saw them:
170
+ * - `landed`: the change is final where this wallet expects it;
171
+ * - `taken`: an input is spent and the final commitments passed `spentAt` without the change, so another transaction
172
+ * spent it, and this step can never land;
173
+ * - `dropped`: no input is spent and the final nullifiers passed `relayedAt` without them: whatever was spending them
174
+ * is gone, a reorg dropped it or the relayer never sent it;
175
+ * - `mined`: the change is in a block, not final yet;
176
+ * - `waiting`: none of these yet. */
177
+ export declare function relayedState(change: Pick<NoteRecord, 'found' | 'watch'> | undefined, inputs: (Position | undefined)[], relayedAt: number | undefined, spentAt: number | undefined): 'landed' | 'taken' | 'dropped' | 'mined' | 'waiting';
178
+ /** The indexer's nullifier count once it is past `block`, so past the nullifiers of a transaction mined there unless a
179
+ * reorg moved them; `undefined` until then. The relayer answers once the block is mined, which may be before the
180
+ * indexer has read it. */
181
+ export declare function nullifiersPast(indexer: Pick<Indexer, 'status'>, block: number): Promise<number | undefined>;
147
182
  /** Whether `op`'s payment or withdrawal is on chain: done, or relayed, which the relayer answers once it is mined. Until
148
183
  * it is final a reorg may still drop it, and the operation relays it again. */
149
184
  export declare function landed(op: SpendOperation): boolean;
150
- /** Takes `op` as far as it can go now, saving before each step that leaves the device. */
185
+ /** Whether `op` can still be cancelled, having paid nothing: a payment, withdrawal or consolidation under way with no
186
+ * request out to the relayer, and no merge of it landed. A step at its relay stage has one out, as its request leaves
187
+ * the moment it gets there; so has one built again after a relay of it reverted, as that request may still land: a
188
+ * JoinSplit's `attempts` count only those, and a merge, whose `attempts` count its prover's refusals too, is `sent`. */
189
+ export declare function cancellable(op: OperationRecord): boolean;
190
+ /** Takes `op` as far as it can go now, saving before each step that leaves the device. One cancelled meanwhile
191
+ * (`cancel`) finishes the stage under way and starts no other: its status is looked at after each, and nothing it does
192
+ * later is kept, as it is out of the state. A relayer that is relaying its request already throws `Relaying`. */
151
193
  export declare function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>;
package/dist/spend.js 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
  import { commit, decodeCode, planPayCode, planSelf, planSend } from '@zerc20/storage-sdk';
13
16
  import { bytesToHex, decodeAbiParameters, hexToBytes, numberToHex, parseAbiParameters, } from 'viem';
14
17
  import { UNWRAP_INTENT } from './chain.js';
@@ -17,7 +20,7 @@ import { core } from './core.js';
17
20
  import { fail } from './errors.js';
18
21
  import { prove } from './prove.js';
19
22
  import { scan } from './scan.js';
20
- import { RelayUnknown, attest, proofOf, relay, relayMerge, relayerFees, requestProof, } from './services.js';
23
+ import { attest, proofOf, relay, relayMerge, relayerFees, requestProof } from './services.js';
21
24
  import { unspent } from './state.js';
22
25
  import { commitmentsAt, noteReceipt, storageState, takeRefresh, tokenIds } from './sync.js';
23
26
  import { aspPath, aspPaths, commitmentPath, commitmentPaths } from './tree.js';
@@ -136,15 +139,22 @@ export function payWith(all, need, fee, onTheWay) {
136
139
  fail('funds-settling', `${need} is not spendable yet: notes or deposits are still settling`);
137
140
  return fail('insufficient-funds', `${need} is more than the balance`);
138
141
  }
139
- /** The smallest public withdrawal that still pays `net` after the protocol's cut. */
142
+ /** The smallest public withdrawal that still pays `net` after the protocol's cut. The cut is a rate of the whole
143
+ * withdrawal, rounded down, and the rate read off one withdrawal (`fee / gross`) is at most its tier's: the smallest
144
+ * gross that pays `net` at that rate is never more than the answer within the tier, so a read or two settles it.
145
+ * ponytail: where a lower tier's bound lies between two reads, that bound may already pay `net`, a little less than
146
+ * the gross this returns; a getter for the schedule's tiers would find it. */
140
147
  export async function grossed(ctx, chainId, tokenId, net) {
141
148
  const chain = chainCtx(ctx, chainId).chain;
142
149
  let gross = net;
143
- for (let i = 0; i < 8; i++) {
150
+ // each read moves on by a unit at least, and a few settle each tier a withdrawal crosses
151
+ for (let i = 0; i < 64; i++) {
144
152
  const paid = gross - (await chain.withdrawFee(tokenId, gross));
145
153
  if (paid >= net)
146
154
  return gross;
147
- gross += net - paid;
155
+ if (paid <= 0n)
156
+ break;
157
+ gross = ((net - 1n) * gross) / paid + 1n;
148
158
  }
149
159
  return fail('unavailable', 'the withdrawal fee schedule does not settle');
150
160
  }
@@ -155,18 +165,25 @@ export async function withdrawalOf(ctx, chainId, tokenId, isPublic, amount, fee)
155
165
  const withdrawn = await grossed(ctx, chainId, tokenId, net);
156
166
  return { withdrawn, protocol: withdrawn - net };
157
167
  }
168
+ /** The rate, in basis points, of a protocol's `cut` out of a withdrawal of `gross`. The cut is rounded down, so this is
169
+ * exact once `gross` is 10,000 base units or more. */
170
+ export const bpsOf = (cut, gross) => gross === 0n ? 0 : Number((cut * 10000n + gross - 1n) / gross);
158
171
  /** The most a payment out of `all` pays its recipient, the relayer's `fee` and the protocol's cut on top
159
172
  * (`withdrawalOf`): a withdrawal of what the relayer leaves, less the cut on it, or a private payment of what the
160
173
  * relayer's grossed fee leaves. Coins one JoinSplit cannot spend together (`pick`) are merged first, each merge
161
- * paying the relayer's grossed fee, as a consolidation's quote does. */
174
+ * paying the relayer's grossed fee, as a consolidation's quote does. `fee` is all the fees take of `all`; where they
175
+ * take it all, what they would be with nothing left to pay. */
162
176
  export async function maxPayment(ctx, chainId, tokenId, isPublic, all, fee) {
163
177
  const whole = all.length < 2 || (all.length === 2 && all.some((c) => c.kind === 'note'));
164
- const merges = whole ? 0n : BigInt(roundsToMerge(all)) * (await grossed(ctx, chainId, tokenId, fee));
165
- const total = all.reduce((s, c) => s + c.value, 0n) - merges;
166
- const left = total - (isPublic ? fee : await grossed(ctx, chainId, tokenId, fee));
178
+ const merges = whole ? 0 : roundsToMerge(all);
179
+ const gross = !isPublic || merges > 0 ? await grossed(ctx, chainId, tokenId, fee) : fee;
180
+ const fees = BigInt(merges) * gross + (isPublic ? fee : gross);
181
+ const total = all.reduce((s, c) => s + c.value, 0n);
182
+ const left = total - fees;
167
183
  if (left <= 0n)
168
- return 0n;
169
- return isPublic ? left - (await chainCtx(ctx, chainId).chain.withdrawFee(tokenId, left)) : left;
184
+ return { amount: 0n, fee: fees, merges };
185
+ const amount = isPublic ? left - (await chainCtx(ctx, chainId).chain.withdrawFee(tokenId, left)) : left;
186
+ return { amount, fee: total - amount, merges };
170
187
  }
171
188
  /** Plans the next step of `op`: a payment's only JoinSplit (or throws why there is none), or a consolidation's next
172
189
  * merge. `null`: wait for the last merge's note to become spendable. `'done'`: nothing left worth merging. */
@@ -460,35 +477,94 @@ const positionsAt = (ctx, chainId, index) => [...ctx.state.notes, ...ctx.state.d
460
477
  .flatMap((r) => r.found.filter((f) => f.index === index));
461
478
  /** The final one of them: an input is final. */
462
479
  const positionOf = (ctx, chainId, index) => positionsAt(ctx, chainId, index).find((f) => !f.tentative);
463
- /** Whether the step's first input is spent on chain: among the final nullifiers (as sync last saw them), or among the
464
- * newer ones. Nothing but the step spends a locked input (rage exit refuses one, and a wallet is open in one tab), so
465
- * a spent input is the step landing. Reads the pages everyone polls, never asks about the nullifier. */
466
- async function inputSpent(ctx, op, step) {
467
- const f = positionOf(ctx, op.chainId, step.inputs[0].index);
468
- if (f?.spent)
480
+ /** Whether any of the step's inputs is spent on chain: among the final nullifiers (as sync last saw them), or among
481
+ * the newer ones. Reads the pages everyone polls, never asks about a nullifier. */
482
+ async function inputsSpent(ctx, op, step) {
483
+ const inputs = step.inputs.map((coin) => positionOf(ctx, op.chainId, coin.index));
484
+ if (inputs.some((f) => f?.spent))
469
485
  return true;
470
- if (!f?.nullifier)
486
+ // copies: sync alone moves the watches, over final entries
487
+ const watches = inputs.flatMap((f) => (f?.nullifier ? [{ ...f.nullifier }] : []));
488
+ if (watches.length === 0)
471
489
  return false;
472
490
  const c = chainCtx(ctx, op.chainId);
473
491
  const { nullifiers } = await c.indexer.status();
474
- // a copy: sync alone moves the watch, over final entries
475
- const watch = { ...f.nullifier };
476
- const hits = await scan([watch], 8, (p) => c.indexer.page('nullifiers', p), watch.scannedTo, nullifiers);
492
+ const from = Math.min(...watches.map((w) => w.scannedTo));
493
+ const hits = await scan(watches, 8, (p) => c.indexer.page('nullifiers', p), from, nullifiers);
477
494
  return hits.length > 0;
478
495
  }
479
- /** Where a JoinSplit relayed when the indexer had `relayedAt` nullifiers stands, by its first input `f` as sync last
480
- * saw it: spent, or the final nullifiers passed it without its own (a reorg dropped it, or the relayer never sent it),
481
- * or neither yet. */
482
- export function relayedState(f, relayedAt) {
483
- if (f?.spent)
496
+ /** Where a relayed step stands, by its `change` (a merge's note) and its `inputs` as sync last saw them:
497
+ * - `landed`: the change is final where this wallet expects it;
498
+ * - `taken`: an input is spent and the final commitments passed `spentAt` without the change, so another transaction
499
+ * spent it, and this step can never land;
500
+ * - `dropped`: no input is spent and the final nullifiers passed `relayedAt` without them: whatever was spending them
501
+ * is gone, a reorg dropped it or the relayer never sent it;
502
+ * - `mined`: the change is in a block, not final yet;
503
+ * - `waiting`: none of these yet. */
504
+ export function relayedState(change, inputs, relayedAt, spentAt) {
505
+ const seen = change?.found.filter((f) => f.confirmed) ?? [];
506
+ if (seen.some((f) => !f.tentative))
484
507
  return 'landed';
485
- return f?.nullifier && f.nullifier.scannedTo >= relayedAt ? 'dropped' : 'waiting';
508
+ if (inputs.some((f) => f?.spent)) {
509
+ // a final position not read yet may be the change
510
+ const missed = spentAt !== undefined &&
511
+ !!change?.watch &&
512
+ change.watch.scannedTo >= spentAt &&
513
+ !change.found.some((f) => !f.tentative);
514
+ if (missed)
515
+ return 'taken';
516
+ }
517
+ else {
518
+ const watches = inputs.flatMap((f) => (f?.nullifier ? [f.nullifier] : []));
519
+ if (relayedAt !== undefined && watches.length > 0 && watches.every((w) => w.scannedTo >= relayedAt))
520
+ return 'dropped';
521
+ }
522
+ return seen.length > 0 ? 'mined' : 'waiting';
523
+ }
524
+ /** `relayedState` of `step`, failing the operation once another transaction took an input. The first time an input
525
+ * is seen spent, short of the step landing, it notes the final commitment count: a later status counts no fewer, so
526
+ * once the change's watch passes it, it has looked wherever that transaction put its notes. */
527
+ async function standing(ctx, op, step) {
528
+ const inputs = step.inputs.map((coin) => positionOf(ctx, op.chainId, coin.index));
529
+ const salt = 'output' in step ? step.output.salt : step.outputs[1].salt;
530
+ const change = ctx.state.notes.find((n) => n.chainId === op.chainId && n.salt === salt);
531
+ const now = relayedState(change, inputs, step.relayedAt, step.spentAt);
532
+ if (now === 'taken')
533
+ fail('spent-elsewhere', 'another device of this wallet spent a note or deposit of it first: it paid nothing');
534
+ if (now !== 'landed' && step.spentAt === undefined && inputs.some((f) => f?.spent)) {
535
+ step.spentAt = (await chainCtx(ctx, op.chainId).indexer.status()).commitmentsFinal;
536
+ await ctx.save();
537
+ }
538
+ return now;
539
+ }
540
+ /** The indexer's nullifier count once it is past `block`, so past the nullifiers of a transaction mined there unless a
541
+ * reorg moved them; `undefined` until then. The relayer answers once the block is mined, which may be before the
542
+ * indexer has read it. */
543
+ export async function nullifiersPast(indexer, block) {
544
+ if ((await indexer.status()).cursor < block)
545
+ return undefined;
546
+ // a status reads its counts before its cursor, so the count comes from the next one
547
+ return (await indexer.status()).nullifiers;
486
548
  }
487
549
  /** Whether `op`'s payment or withdrawal is on chain: done, or relayed, which the relayer answers once it is mined. Until
488
550
  * it is final a reorg may still drop it, and the operation relays it again. */
489
551
  export function landed(op) {
490
552
  return (op.status === 'done' || (op.status === 'pending' && op.kind !== 'consolidate' && op.data.step?.stage === 'relayed'));
491
553
  }
554
+ /** Whether `op` can still be cancelled, having paid nothing: a payment, withdrawal or consolidation under way with no
555
+ * request out to the relayer, and no merge of it landed. A step at its relay stage has one out, as its request leaves
556
+ * the moment it gets there; so has one built again after a relay of it reverted, as that request may still land: a
557
+ * JoinSplit's `attempts` count only those, and a merge, whose `attempts` count its prover's refusals too, is `sent`. */
558
+ export function cancellable(op) {
559
+ if (op.status !== 'pending' || op.kind === 'deposit' || op.data.paid > 0n)
560
+ return false;
561
+ const step = op.data.step;
562
+ if (!step)
563
+ return true;
564
+ if ('output' in step)
565
+ return !step.sent && step.stage !== 'proved' && step.stage !== 'relayed';
566
+ return step.attempts === 0 && step.stage !== 'attested' && step.stage !== 'relayed';
567
+ }
492
568
  function markSpent(ctx, op, step) {
493
569
  for (const coin of step.inputs)
494
570
  for (const f of positionsAt(ctx, op.chainId, coin.index))
@@ -501,14 +577,18 @@ function placeChange(ctx, op, salt, index) {
501
577
  note.found.push({ index, tentative: true, spent: false });
502
578
  }
503
579
  }
504
- /** Takes `op` as far as it can go now, saving before each step that leaves the device. */
580
+ /** Takes `op` as far as it can go now, saving before each step that leaves the device. One cancelled meanwhile
581
+ * (`cancel`) finishes the stage under way and starts no other: its status is looked at after each, and nothing it does
582
+ * later is kept, as it is out of the state. A relayer that is relaying its request already throws `Relaying`. */
505
583
  export async function advanceSpend(ctx, op) {
506
584
  const data = op.data;
507
585
  const c = chainCtx(ctx, op.chainId);
508
586
  for (;;) {
587
+ if (op.status !== 'pending')
588
+ return;
509
589
  if (!data.step) {
510
590
  const step = await planStep(ctx, op);
511
- if (!step)
591
+ if (!step || op.status !== 'pending')
512
592
  return;
513
593
  if (step === 'done') {
514
594
  op.status = 'done';
@@ -529,6 +609,8 @@ export async function advanceSpend(ctx, op) {
529
609
  step.stage = 'noted';
530
610
  await ctx.save();
531
611
  }
612
+ if (op.status !== 'pending')
613
+ return;
532
614
  if (step.stage === 'noted') {
533
615
  const built = await witnessOf(ctx, op, step);
534
616
  if (!built)
@@ -573,6 +655,8 @@ export async function advanceSpend(ctx, op) {
573
655
  step.stage = 'proved';
574
656
  await ctx.save();
575
657
  }
658
+ if (op.status !== 'pending')
659
+ return;
576
660
  if (step.stage === 'proved') {
577
661
  const attestation = await attest(c.config, step.statement, step.witness);
578
662
  step.request = { ...step.request, attestation };
@@ -580,19 +664,25 @@ export async function advanceSpend(ctx, op) {
580
664
  step.stage = 'attested';
581
665
  await ctx.save();
582
666
  }
667
+ if (op.status !== 'pending')
668
+ return;
669
+ if (step.stage === 'attested' && step.relayedAt !== undefined) {
670
+ // a relay of it reverted on an input spent already: an earlier relay of it landed, one whose answer was lost, or
671
+ // another transaction spent the input. Relaying it again would tell nothing more
672
+ const now = await standing(ctx, op, step);
673
+ if (now === 'waiting')
674
+ return;
675
+ if (now === 'dropped')
676
+ delete step.relayedAt;
677
+ else
678
+ step.stage = 'relayed';
679
+ await ctx.save();
680
+ }
583
681
  if (step.stage === 'attested') {
584
682
  const relayerOut = step.outputs.find((o) => o.role === 'relayer');
585
- let result;
586
- try {
587
- result = await relay(c.config, step.request, relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined);
588
- }
589
- catch (e) {
590
- if (e instanceof RelayUnknown)
591
- return; // try the same request again next time
592
- throw e;
593
- }
683
+ const result = await relay(c.config, step.request, relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined);
594
684
  if ('reverted' in result) {
595
- if (!(await inputSpent(ctx, op, step))) {
685
+ if (!(await inputsSpent(ctx, op, step))) {
596
686
  // stale roots, most likely: prove again with the same outputs, next sync. A JoinSplit that keeps reverting
597
687
  // is not on chain and cannot land, so failing it frees its inputs.
598
688
  if (++step.attempts > 3)
@@ -601,21 +691,24 @@ export async function advanceSpend(ctx, op) {
601
691
  await ctx.save();
602
692
  return;
603
693
  }
604
- // an earlier relay of it landed, one whose answer was lost
605
- }
606
- else {
607
- placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
608
- data.txHashes = [...(data.txHashes ?? []), result.txHash];
694
+ // the indexer counts the nullifiers of whatever spent it already
695
+ step.relayedAt = (await c.indexer.status()).nullifiers;
696
+ await ctx.save();
697
+ return;
609
698
  }
610
- step.relayedAt = (await c.indexer.status()).nullifiers;
699
+ placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
700
+ data.txHashes = [...(data.txHashes ?? []), result.txHash];
701
+ step.relayedBlock = result.block;
611
702
  step.stage = 'relayed';
612
703
  await ctx.save();
613
704
  }
614
705
  if (step.stage === 'relayed') {
615
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt);
706
+ step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock);
707
+ const now = await standing(ctx, op, step);
616
708
  if (now === 'dropped') {
617
709
  // relay it again next sync; if it did land after all, that reverts and finds it
618
710
  step.stage = 'attested';
711
+ delete step.relayedAt;
619
712
  await ctx.save();
620
713
  }
621
714
  if (now !== 'landed')
@@ -648,6 +741,8 @@ async function advanceMerge(ctx, op, step) {
648
741
  step.stage = 'noted';
649
742
  await ctx.save();
650
743
  }
744
+ if (op.status !== 'pending')
745
+ return false;
651
746
  if (step.stage === 'noted') {
652
747
  const built = await mergeWitnessOf(ctx, op, step);
653
748
  if (!built)
@@ -682,6 +777,8 @@ async function advanceMerge(ctx, op, step) {
682
777
  step.stage = 'attested';
683
778
  await ctx.save();
684
779
  }
780
+ if (op.status !== 'pending')
781
+ return false;
685
782
  if (step.stage === 'attested') {
686
783
  await requestProof(c.config, prover, step.statement, step.witness, step.inputs.length);
687
784
  delete step.witness; // the prover has it: the inputs' openings need not stay saved here
@@ -705,34 +802,43 @@ async function advanceMerge(ctx, op, step) {
705
802
  step.stage = 'proved';
706
803
  await ctx.save();
707
804
  }
805
+ if (op.status !== 'pending')
806
+ return false;
807
+ if (step.stage === 'proved' && step.relayedAt !== undefined) {
808
+ // as a JoinSplit's, told apart by its note
809
+ const now = await standing(ctx, op, step);
810
+ if (now === 'waiting')
811
+ return false;
812
+ if (now === 'dropped')
813
+ delete step.relayedAt;
814
+ else
815
+ step.stage = 'relayed';
816
+ await ctx.save();
817
+ }
708
818
  if (step.stage === 'proved') {
709
- let result;
710
- try {
711
- result = await relayMerge(c.config, step.request);
712
- }
713
- catch (e) {
714
- if (e instanceof RelayUnknown)
715
- return false; // try the same request again next time
716
- throw e;
717
- }
819
+ const result = await relayMerge(c.config, step.request);
718
820
  if ('reverted' in result) {
719
821
  // stale roots, most likely: built again with the same output, next sync
720
- if (!(await inputSpent(ctx, op, step)))
822
+ if (!(await inputsSpent(ctx, op, step))) {
823
+ step.sent = true;
721
824
  return again(result.reverted);
722
- // an earlier relay of it landed, one whose answer was lost
723
- }
724
- else {
725
- placeChange(ctx, op, step.output.salt, result.noteIndex);
726
- data.txHashes = [...(data.txHashes ?? []), result.txHash];
825
+ }
826
+ step.relayedAt = (await c.indexer.status()).nullifiers;
827
+ await ctx.save();
828
+ return false;
727
829
  }
728
- step.relayedAt = (await c.indexer.status()).nullifiers;
830
+ placeChange(ctx, op, step.output.salt, result.noteIndex);
831
+ data.txHashes = [...(data.txHashes ?? []), result.txHash];
832
+ step.relayedBlock = result.block;
729
833
  step.stage = 'relayed';
730
834
  await ctx.save();
731
835
  }
732
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt);
836
+ step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock);
837
+ const now = await standing(ctx, op, step);
733
838
  if (now === 'dropped') {
734
839
  // relay it again next sync; if it did land after all, that reverts and finds it
735
840
  step.stage = 'proved';
841
+ delete step.relayedAt;
736
842
  await ctx.save();
737
843
  }
738
844
  if (now !== 'landed')