@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.
- package/LICENSE +17 -20
- package/README.md +2 -2
- package/dist/accountId.d.ts +5 -0
- package/dist/accountId.js +19 -0
- package/dist/accountId.js.map +1 -0
- package/dist/chain.d.ts +86 -9
- package/dist/chain.js +61 -33
- package/dist/chain.js.map +1 -1
- package/dist/context.d.ts +8 -2
- package/dist/context.js +15 -2
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +35 -5
- package/dist/core.js +98 -25
- package/dist/core.js.map +1 -1
- package/dist/errors.d.ts +20 -3
- package/dist/errors.js +7 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/network.d.ts +4 -2
- package/dist/network.js +3 -1
- package/dist/network.js.map +1 -1
- package/dist/pos.d.ts +10 -1
- package/dist/pos.js +219 -55
- package/dist/pos.js.map +1 -1
- package/dist/prove.d.ts +3 -0
- package/dist/prove.js +9 -0
- package/dist/prove.js.map +1 -1
- package/dist/rageExit.d.ts +27 -4
- package/dist/rageExit.js +160 -49
- package/dist/rageExit.js.map +1 -1
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +13 -1
- package/dist/seed.js.map +1 -1
- package/dist/servers.d.ts +5 -0
- package/dist/servers.js +2 -0
- package/dist/servers.js.map +1 -1
- package/dist/services.d.ts +14 -6
- package/dist/services.js +35 -14
- package/dist/services.js.map +1 -1
- package/dist/spend.d.ts +53 -11
- package/dist/spend.js +171 -65
- package/dist/spend.js.map +1 -1
- package/dist/state.d.ts +21 -0
- package/dist/state.js.map +1 -1
- package/dist/sync.d.ts +10 -5
- package/dist/sync.js +49 -18
- package/dist/sync.js.map +1 -1
- package/dist/wallet.d.ts +132 -21
- package/dist/wallet.js +342 -91
- package/dist/wallet.js.map +1 -1
- package/package.json +3 -3
- package/src/accountId.ts +22 -0
- package/src/chain.ts +101 -49
- package/src/context.ts +27 -3
- package/src/core.ts +112 -26
- package/src/errors.ts +15 -2
- package/src/index.ts +15 -2
- package/src/network.ts +6 -3
- package/src/pos.ts +247 -61
- package/src/prove.ts +10 -0
- package/src/rageExit.ts +237 -61
- package/src/seed.ts +24 -1
- package/src/servers.ts +9 -1
- package/src/services.ts +36 -22
- package/src/spend.ts +188 -75
- package/src/state.ts +25 -1
- package/src/sync.ts +54 -19
- package/src/wallet.ts +437 -109
- package/wasm/zerc20_wallet_core.d.ts +1 -1
- package/wasm/zerc20_wallet_core.js +1 -1
- 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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
632
|
-
* newer ones.
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
641
|
-
const
|
|
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
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
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
|
-
//
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
| {
|
|
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
|
-
: {
|
|
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
|
|
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)
|
|
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
|
|
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,
|
|
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
|
|
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++)
|
|
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;
|