@zerc20/wallet-sdk 0.0.0-stage → 0.2.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 +24 -0
- package/README.md +26 -2
- package/dist/burnWorker.d.ts +1 -0
- package/dist/burnWorker.js +21 -0
- package/dist/burnWorker.js.map +1 -0
- package/dist/chain.d.ts +359 -0
- package/dist/chain.js +155 -0
- package/dist/chain.js.map +1 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +40 -0
- package/dist/context.js.map +1 -0
- package/dist/core.d.ts +43 -0
- package/dist/core.js +106 -0
- package/dist/core.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/network.d.ts +66 -0
- package/dist/network.js +55 -0
- package/dist/network.js.map +1 -0
- package/dist/pos.d.ts +93 -0
- package/dist/pos.js +302 -0
- package/dist/pos.js.map +1 -0
- package/dist/prove.d.ts +13 -0
- package/dist/prove.js +53 -0
- package/dist/prove.js.map +1 -0
- package/dist/rageExit.d.ts +5 -0
- package/dist/rageExit.js +146 -0
- package/dist/rageExit.js.map +1 -0
- package/dist/scan.d.ts +17 -0
- package/dist/scan.js +47 -0
- package/dist/scan.js.map +1 -0
- package/dist/seed.d.ts +8 -0
- package/dist/seed.js +35 -0
- package/dist/seed.js.map +1 -0
- package/dist/servers.d.ts +60 -0
- package/dist/servers.js +117 -0
- package/dist/servers.js.map +1 -0
- package/dist/services.d.ts +62 -0
- package/dist/services.js +131 -0
- package/dist/services.js.map +1 -0
- package/dist/spend.d.ts +151 -0
- package/dist/spend.js +745 -0
- package/dist/spend.js.map +1 -0
- package/dist/state.d.ts +174 -0
- package/dist/state.js +45 -0
- package/dist/state.js.map +1 -0
- package/dist/sync.d.ts +84 -0
- package/dist/sync.js +615 -0
- package/dist/sync.js.map +1 -0
- package/dist/tree.d.ts +19 -0
- package/dist/tree.js +63 -0
- package/dist/tree.js.map +1 -0
- package/dist/wallet.d.ts +303 -0
- package/dist/wallet.js +1002 -0
- package/dist/wallet.js.map +1 -0
- package/package.json +60 -4
- package/src/burnWorker.ts +25 -0
- package/src/chain.ts +228 -0
- package/src/context.ts +84 -0
- package/src/core.ts +147 -0
- package/src/errors.ts +63 -0
- package/src/index.ts +41 -0
- package/src/network.ts +94 -0
- package/src/pos.ts +415 -0
- package/src/prove.ts +67 -0
- package/src/rageExit.ts +168 -0
- package/src/scan.ts +62 -0
- package/src/seed.ts +46 -0
- package/src/servers.ts +172 -0
- package/src/services.ts +195 -0
- package/src/snarkjs.d.ts +11 -0
- package/src/spend.ts +912 -0
- package/src/state.ts +211 -0
- package/src/sync.ts +698 -0
- package/src/tree.ts +108 -0
- package/src/wallet.ts +1316 -0
- package/wasm/zerc20_wallet_core.d.ts +176 -0
- package/wasm/zerc20_wallet_core.js +783 -0
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
- package/wasm/zerc20_wallet_core_bg.wasm.d.ts +32 -0
package/src/spend.ts
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
// Spending: which notes and deposits to use, and each JoinSplit or merge as steps that are saved before they leave
|
|
2
|
+
// the device (storage notes, proof, attestation, relay), so `sync` resumes an operation wherever it stopped.
|
|
3
|
+
//
|
|
4
|
+
// A JoinSplit takes at most two inputs, and a deposit only as the first. A send or a withdrawal is one JoinSplit, or
|
|
5
|
+
// fails when none pays it. A consolidation (`Destination` `self`) is a Nova merge of up to `MAX_MERGE_INPUTS` notes
|
|
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
|
+
// another merge, once a root is over the first one's note.
|
|
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.
|
|
12
|
+
|
|
13
|
+
import { type Receipt, type Recipient, commit, decodeCode, planPayCode, planSelf, planSend } from '@zerc20/storage-sdk';
|
|
14
|
+
import {
|
|
15
|
+
type Address,
|
|
16
|
+
type Hex,
|
|
17
|
+
bytesToHex,
|
|
18
|
+
decodeAbiParameters,
|
|
19
|
+
hexToBytes,
|
|
20
|
+
numberToHex,
|
|
21
|
+
parseAbiParameters,
|
|
22
|
+
} from 'viem';
|
|
23
|
+
import { UNWRAP_INTENT } from './chain.js';
|
|
24
|
+
import { type Ctx, chainCtx, spendKey } from './context.js';
|
|
25
|
+
import { core } from './core.js';
|
|
26
|
+
import { fail } from './errors.js';
|
|
27
|
+
import { prove } from './prove.js';
|
|
28
|
+
import { scan } from './scan.js';
|
|
29
|
+
import {
|
|
30
|
+
type Relayed,
|
|
31
|
+
RelayUnknown,
|
|
32
|
+
attest,
|
|
33
|
+
proofOf,
|
|
34
|
+
relay,
|
|
35
|
+
relayMerge,
|
|
36
|
+
relayerFees,
|
|
37
|
+
requestProof,
|
|
38
|
+
} from './services.js';
|
|
39
|
+
import type { NoteRecord, OperationRecord, Position, SpendOperation } from './state.js';
|
|
40
|
+
import { commitmentsAt, noteReceipt, storageState, takeRefresh, tokenIds } from './sync.js';
|
|
41
|
+
import { aspPath, aspPaths, commitmentPath, commitmentPaths } from './tree.js';
|
|
42
|
+
|
|
43
|
+
/** Something spendable. */
|
|
44
|
+
export interface Coin {
|
|
45
|
+
kind: 'note' | 'burn';
|
|
46
|
+
index: number;
|
|
47
|
+
value: bigint;
|
|
48
|
+
/** For a note paid to a receive code, the code's index: the code's own key owns it. Otherwise the wallet's key does. */
|
|
49
|
+
keyIndex?: number;
|
|
50
|
+
/** A note's salt. */
|
|
51
|
+
salt?: Hex;
|
|
52
|
+
/** A deposit's burn salt and sender. */
|
|
53
|
+
burnSalt?: Hex;
|
|
54
|
+
sender?: Hex;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Output {
|
|
58
|
+
publicKey: Hex;
|
|
59
|
+
value: bigint;
|
|
60
|
+
salt: Hex;
|
|
61
|
+
role: 'recipient' | 'change' | 'relayer';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One JoinSplit and how far it got. */
|
|
65
|
+
export interface Step {
|
|
66
|
+
inputs: Coin[];
|
|
67
|
+
outputs: [Output, Output];
|
|
68
|
+
/** `unwrap` pays it out as the underlying asset rather than zERC20. */
|
|
69
|
+
withdraw: { recipient: Address; amount: bigint; unwrap?: boolean } | null;
|
|
70
|
+
entropy: Hex;
|
|
71
|
+
stage: 'planned' | 'noted' | 'proved' | 'attested' | 'relayed';
|
|
72
|
+
witness?: string;
|
|
73
|
+
statement?: Record<string, string>;
|
|
74
|
+
request?: object;
|
|
75
|
+
/** Reverts with no sign of it on chain: proved again with the same outputs a few times, then failed. */
|
|
76
|
+
attempts: number;
|
|
77
|
+
/** Once relayed: the indexer's nullifier count just after. Final nullifiers past it, and the input's still not among
|
|
78
|
+
* them, means a reorg dropped the transaction. */
|
|
79
|
+
relayedAt?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The most inputs one merge spends: the attester takes requests up to 2 MB, at 8 KiB of sealed witness per input
|
|
83
|
+
* sent as hex, and the prover folds them in one job. */
|
|
84
|
+
export const MAX_MERGE_INPUTS = 100;
|
|
85
|
+
|
|
86
|
+
/** One Nova merge of a consolidation and how far it got. */
|
|
87
|
+
export interface MergeStep {
|
|
88
|
+
inputs: Coin[];
|
|
89
|
+
/** The note it makes: the inputs less `fee`. */
|
|
90
|
+
output: Output;
|
|
91
|
+
/** Paid publicly to `feeRecipient`, the relayer, grossed up for the protocol's cut. */
|
|
92
|
+
fee: bigint;
|
|
93
|
+
feeRecipient: Address;
|
|
94
|
+
stage: 'planned' | 'noted' | 'attested' | 'proving' | 'proved' | 'relayed';
|
|
95
|
+
/** Only until the prover has it: it holds the inputs' openings. */
|
|
96
|
+
witness?: string;
|
|
97
|
+
/** The attester's `Merge`, and from `attested` on the `MergeRequest` short of its proof. */
|
|
98
|
+
statement?: Record<string, unknown>;
|
|
99
|
+
request?: Record<string, unknown>;
|
|
100
|
+
/** Refused by the prover, or reverting with no sign of it on chain: built again with the same output a few times,
|
|
101
|
+
* then failed. */
|
|
102
|
+
attempts: number;
|
|
103
|
+
relayedAt?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Where a send or withdrawal goes; `self` merges the wallet's own notes and nothing leaves. */
|
|
107
|
+
export type Destination =
|
|
108
|
+
| { kind: 'address'; recipient: Recipient }
|
|
109
|
+
| { kind: 'code'; code: string }
|
|
110
|
+
| { kind: 'public'; address: Address; unwrap?: boolean }
|
|
111
|
+
| { kind: 'self' };
|
|
112
|
+
|
|
113
|
+
export interface SpendData {
|
|
114
|
+
to: Destination;
|
|
115
|
+
/** The largest total fee the caller allowed. */
|
|
116
|
+
maxFee: bigint;
|
|
117
|
+
step: Step | MergeStep | null;
|
|
118
|
+
/** Fees paid so far. */
|
|
119
|
+
paid: bigint;
|
|
120
|
+
/** A consolidation of only these notes and deposits (commitment indices). */
|
|
121
|
+
chosen?: number[];
|
|
122
|
+
/** The notes to self its steps made (a payment's change, a consolidation's merged notes), by salt. */
|
|
123
|
+
changes?: Hex[];
|
|
124
|
+
/** The transactions relayed for it. */
|
|
125
|
+
txHashes?: Hex[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The notes and deposits an operation in flight spends: no other operation may take them. */
|
|
129
|
+
export const inputsOf = (o: OperationRecord): Coin[] =>
|
|
130
|
+
o.status === 'pending' && o.kind !== 'deposit' ? (o.data.step?.inputs ?? []) : [];
|
|
131
|
+
|
|
132
|
+
/** A field element's worth of randomness: 31 bytes, so below the BN254 modulus. */
|
|
133
|
+
const fresh = (): Hex => bytesToHex(crypto.getRandomValues(new Uint8Array(32)).fill(0, 0, 1));
|
|
134
|
+
const word = (v: bigint): Hex => numberToHex(v, { size: 32 });
|
|
135
|
+
const bytes32 = (v: bigint): Uint8Array => hexToBytes(word(v));
|
|
136
|
+
|
|
137
|
+
/** Final, unspent, under the latest proved root of size `rootSize`, and not taken by another operation. */
|
|
138
|
+
export function coins(ctx: Ctx, chainId: bigint, tokenId: number, rootSize: number): Coin[] {
|
|
139
|
+
const locked = new Set(
|
|
140
|
+
ctx.state.ops.filter((o) => o.chainId === chainId).flatMap((o) => inputsOf(o).map((c) => c.index)),
|
|
141
|
+
);
|
|
142
|
+
// final or not: once a proved root the chain accepts covers it, a reorg that moves it takes that root back too, and
|
|
143
|
+
// a JoinSplit against it then fails on chain, which frees its inputs
|
|
144
|
+
const usable = (f: { index: number; spent: boolean }) => !f.spent && f.index < rootSize && !locked.has(f.index);
|
|
145
|
+
const notes = ctx.state.notes
|
|
146
|
+
.filter((n) => n.chainId === chainId && n.tokenId === tokenId)
|
|
147
|
+
.flatMap((n) =>
|
|
148
|
+
n.found
|
|
149
|
+
.filter((f) => f.confirmed && usable(f))
|
|
150
|
+
.map((f) => ({
|
|
151
|
+
kind: 'note' as const,
|
|
152
|
+
index: f.index,
|
|
153
|
+
value: n.value,
|
|
154
|
+
...(n.keyIndex === undefined ? {} : { keyIndex: n.keyIndex }),
|
|
155
|
+
salt: n.salt,
|
|
156
|
+
})),
|
|
157
|
+
);
|
|
158
|
+
const deposits = ctx.state.deposits
|
|
159
|
+
.filter((d) => d.chainId === chainId)
|
|
160
|
+
.flatMap((d) =>
|
|
161
|
+
d.found
|
|
162
|
+
.filter((f) => f.tokenId === tokenId && f.asp === 'approved' && f.amount !== null && usable(f))
|
|
163
|
+
.map((f) => ({ kind: 'burn' as const, index: f.index, value: f.amount!, burnSalt: d.salt, sender: f.sender! })),
|
|
164
|
+
);
|
|
165
|
+
return [...notes, ...deposits].filter((c) => c.value > 0n);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether any of `tokenId`'s notes or deposits is on chain but not spendable yet: not final or confirmed, not
|
|
169
|
+
* screened, or not under the proved root of size `rootSize`. A note only announced does not count: it may never land. */
|
|
170
|
+
export function settling(ctx: Ctx, chainId: bigint, tokenId: number, rootSize: number): boolean {
|
|
171
|
+
const waiting = (f: { index: number; spent: boolean }) => !f.spent && f.index >= rootSize;
|
|
172
|
+
// a zero-value change is never spendable: it is there to carry its payment's receipt
|
|
173
|
+
const notes = ctx.state.notes.filter((n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n);
|
|
174
|
+
const deposits = ctx.state.deposits.filter((d) => d.chainId === chainId);
|
|
175
|
+
return (
|
|
176
|
+
notes.some((n) => n.found.some((f) => waiting(f) || (!f.spent && !f.confirmed))) ||
|
|
177
|
+
deposits.some((d) =>
|
|
178
|
+
d.found.some(
|
|
179
|
+
(f) => f.tokenId === tokenId && f.asp !== 'rejected' && (waiting(f) || (!f.spent && f.asp === null)),
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The index of the first of `sorted` (ascending by value) worth at least `x`. */
|
|
186
|
+
function atLeast(sorted: Coin[], x: bigint): number {
|
|
187
|
+
let [lo, hi] = [0, sorted.length];
|
|
188
|
+
while (lo < hi) {
|
|
189
|
+
const mid = (lo + hi) >> 1;
|
|
190
|
+
if (sorted[mid].value < x) lo = mid + 1;
|
|
191
|
+
else hi = mid;
|
|
192
|
+
}
|
|
193
|
+
return lo;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The inputs of one JoinSplit worth at least `need`: one coin, or a coin plus a note (a deposit can only be the
|
|
197
|
+
* first input). The smallest total wins. O(n log n), for wallets holding many notes. */
|
|
198
|
+
export function pick(all: Coin[], need: bigint): Coin[] | null {
|
|
199
|
+
const sorted = [...all].sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0));
|
|
200
|
+
const notes = sorted.filter((c) => c.kind === 'note');
|
|
201
|
+
let best: Coin[] | null = null;
|
|
202
|
+
let total = 0n;
|
|
203
|
+
const single = sorted[atLeast(sorted, need)];
|
|
204
|
+
if (single) [best, total] = [[single], single.value];
|
|
205
|
+
for (const a of sorted) {
|
|
206
|
+
let i = atLeast(notes, need - a.value);
|
|
207
|
+
if (notes[i] === a) i++;
|
|
208
|
+
const b = notes[i];
|
|
209
|
+
if (b && (!best || a.value + b.value < total)) [best, total] = [[a, b], a.value + b.value];
|
|
210
|
+
}
|
|
211
|
+
return best;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A consolidation's next merge: up to `MAX_MERGE_INPUTS` coins, the largest first. `null` when a merge would leave
|
|
215
|
+
* nothing better: no coin, a lone note, or no more than the relayer's `fee`.
|
|
216
|
+
* ponytail: one merge per root; merge disjoint sets in parallel if consolidations of thousands get slow. */
|
|
217
|
+
export function mergeRound(all: Coin[], fee: bigint): Coin[] | null {
|
|
218
|
+
const merge = [...all]
|
|
219
|
+
.sort((a, b) => (b.value > a.value ? 1 : b.value < a.value ? -1 : 0))
|
|
220
|
+
.slice(0, MAX_MERGE_INPUTS);
|
|
221
|
+
if (merge.length === 0 || (merge.length === 1 && merge[0].kind === 'note')) return null;
|
|
222
|
+
return merge.reduce((s, c) => s + c.value, 0n) > fee ? merge : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** How many merges consolidate `all` into one note: each spends up to `MAX_MERGE_INPUTS`, the one before's note among
|
|
226
|
+
* them. A lone deposit takes one, to become a note. */
|
|
227
|
+
export function roundsToMerge(all: Coin[]): number {
|
|
228
|
+
const n = all.length;
|
|
229
|
+
if (n === 0 || (n === 1 && all[0].kind === 'note')) return 0;
|
|
230
|
+
return 1 + Math.ceil(Math.max(0, n - MAX_MERGE_INPUTS) / (MAX_MERGE_INPUTS - 1));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What a consolidation merges: everything, or only the chosen notes and deposits (by commitment index) and the
|
|
234
|
+
* notes its earlier rounds made of them. */
|
|
235
|
+
export function mergePool(all: Coin[], chosen: number[] | undefined, changes: Hex[] = []): Coin[] {
|
|
236
|
+
if (!chosen) return all;
|
|
237
|
+
return all.filter((c) => chosen.includes(c.index) || (c.kind === 'note' && changes.includes(c.salt!)));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The inputs of the one JoinSplit that pays `need` (see `pick`), or why there is none: merging everything worth it
|
|
241
|
+
* into one note first would pay it, coins `onTheWay` might, or nothing will. A payment never waits or merges itself:
|
|
242
|
+
* that is the app's call. */
|
|
243
|
+
export function payWith(all: Coin[], need: bigint, fee: bigint, onTheWay: boolean): Coin[] {
|
|
244
|
+
const inputs = pick(all, need);
|
|
245
|
+
if (inputs) return inputs;
|
|
246
|
+
const merged = all.reduce((s, c) => s + c.value, 0n) - BigInt(roundsToMerge(all)) * fee;
|
|
247
|
+
if (merged >= need) fail('needs-consolidation', `no single JoinSplit pays ${need}: consolidate first, then pay`);
|
|
248
|
+
if (onTheWay) fail('funds-settling', `${need} is not spendable yet: notes or deposits are still settling`);
|
|
249
|
+
return fail('insufficient-funds', `${need} is more than the balance`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The smallest public withdrawal that still pays `net` after the protocol's cut. */
|
|
253
|
+
export async function grossed(ctx: Ctx, chainId: bigint, tokenId: number, net: bigint): Promise<bigint> {
|
|
254
|
+
const chain = chainCtx(ctx, chainId).chain;
|
|
255
|
+
let gross = net;
|
|
256
|
+
for (let i = 0; i < 8; i++) {
|
|
257
|
+
const paid = gross - (await chain.withdrawFee(tokenId, gross));
|
|
258
|
+
if (paid >= net) return gross;
|
|
259
|
+
gross += net - paid;
|
|
260
|
+
}
|
|
261
|
+
return fail('unavailable', 'the withdrawal fee schedule does not settle');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A payment's one public withdrawal, and the protocol's cut of it, which the payer pays on top: a withdrawal's pays
|
|
265
|
+
* its recipient `amount` (and the relayer by a note), a private payment's pays the relayer its `fee`. */
|
|
266
|
+
export async function withdrawalOf(
|
|
267
|
+
ctx: Ctx,
|
|
268
|
+
chainId: bigint,
|
|
269
|
+
tokenId: number,
|
|
270
|
+
isPublic: boolean,
|
|
271
|
+
amount: bigint,
|
|
272
|
+
fee: bigint,
|
|
273
|
+
): Promise<{ withdrawn: bigint; protocol: bigint }> {
|
|
274
|
+
const net = isPublic ? amount : fee;
|
|
275
|
+
const withdrawn = await grossed(ctx, chainId, tokenId, net);
|
|
276
|
+
return { withdrawn, protocol: withdrawn - net };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The most a payment out of `all` pays its recipient, the relayer's `fee` and the protocol's cut on top
|
|
280
|
+
* (`withdrawalOf`): a withdrawal of what the relayer leaves, less the cut on it, or a private payment of what the
|
|
281
|
+
* 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. */
|
|
283
|
+
export async function maxPayment(
|
|
284
|
+
ctx: Ctx,
|
|
285
|
+
chainId: bigint,
|
|
286
|
+
tokenId: number,
|
|
287
|
+
isPublic: boolean,
|
|
288
|
+
all: Coin[],
|
|
289
|
+
fee: bigint,
|
|
290
|
+
): Promise<bigint> {
|
|
291
|
+
const whole = all.length < 2 || (all.length === 2 && all.some((c) => c.kind === 'note'));
|
|
292
|
+
const merges = whole ? 0n : BigInt(roundsToMerge(all)) * (await grossed(ctx, chainId, tokenId, fee));
|
|
293
|
+
const total = all.reduce((s, c) => s + c.value, 0n) - merges;
|
|
294
|
+
const left = total - (isPublic ? fee : await grossed(ctx, chainId, tokenId, fee));
|
|
295
|
+
if (left <= 0n) return 0n;
|
|
296
|
+
return isPublic ? left - (await chainCtx(ctx, chainId).chain.withdrawFee(tokenId, left)) : left;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Plans the next step of `op`: a payment's only JoinSplit (or throws why there is none), or a consolidation's next
|
|
300
|
+
* merge. `null`: wait for the last merge's note to become spendable. `'done'`: nothing left worth merging. */
|
|
301
|
+
export async function planStep(ctx: Ctx, op: SpendOperation): Promise<Step | MergeStep | null | 'done'> {
|
|
302
|
+
const data = op.data;
|
|
303
|
+
const c = chainCtx(ctx, op.chainId);
|
|
304
|
+
const root = (await c.indexer.status()).latestRoot;
|
|
305
|
+
const all = coins(ctx, op.chainId, op.tokenId, root?.size ?? 0);
|
|
306
|
+
const { relayer, publicKey: relayerKey, fees } = await relayerFees(c.config);
|
|
307
|
+
const fee = fees.get(op.tokenId)?.fee ?? fail('unavailable', `the relayer does not relay token ${op.tokenId}`);
|
|
308
|
+
const self = spendKey(ctx).publicKey;
|
|
309
|
+
const out = (publicKey: Hex, value: bigint, role: Output['role'], salt: Hex = fresh()): Output => ({
|
|
310
|
+
publicKey,
|
|
311
|
+
value,
|
|
312
|
+
salt,
|
|
313
|
+
role,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (data.to.kind === 'self') {
|
|
317
|
+
if (!ctx.network.mergeProver) fail('invalid', 'this network has no merge prover');
|
|
318
|
+
// the last merge's note first has to land under a proved root
|
|
319
|
+
if (hasPendingChange(ctx, op, root?.size ?? 0)) return null;
|
|
320
|
+
// one still settling is waited for, not left out
|
|
321
|
+
if (data.chosen && chosenSettling(ctx, op.chainId, data.chosen, all)) return null;
|
|
322
|
+
const merge = mergeRound(mergePool(all, data.chosen, data.changes), fee);
|
|
323
|
+
const gross = await grossed(ctx, op.chainId, op.tokenId, fee);
|
|
324
|
+
const total = merge?.reduce((s, x) => s + x.value, 0n) ?? 0n;
|
|
325
|
+
if (!merge || total <= gross) {
|
|
326
|
+
if (data.chosen && !data.changes?.length)
|
|
327
|
+
fail('invalid', 'the chosen notes and deposits are worth no more than the fee');
|
|
328
|
+
return 'done';
|
|
329
|
+
}
|
|
330
|
+
if (data.paid + gross > data.maxFee)
|
|
331
|
+
fail('fee-too-high', `consolidating would exceed the fee limit ${data.maxFee}`);
|
|
332
|
+
return {
|
|
333
|
+
inputs: merge,
|
|
334
|
+
output: out(self, total - gross, 'change'),
|
|
335
|
+
fee: gross,
|
|
336
|
+
feeRecipient: relayer,
|
|
337
|
+
stage: 'planned',
|
|
338
|
+
attempts: 0,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// the only step: a private payment pays the relayer publicly (both outputs are taken), a withdrawal by a note
|
|
343
|
+
const { withdrawn, protocol } = await withdrawalOf(
|
|
344
|
+
ctx,
|
|
345
|
+
op.chainId,
|
|
346
|
+
op.tokenId,
|
|
347
|
+
data.to.kind === 'public',
|
|
348
|
+
op.amount,
|
|
349
|
+
fee,
|
|
350
|
+
);
|
|
351
|
+
const need = op.amount + fee + protocol;
|
|
352
|
+
const onTheWay =
|
|
353
|
+
ctx.state.ops.some((o) => o !== op && o.status === 'pending') ||
|
|
354
|
+
settling(ctx, op.chainId, op.tokenId, root?.size ?? 0);
|
|
355
|
+
const inputs = payWith(all, need, fee, onTheWay);
|
|
356
|
+
const total = inputs.reduce((s, x) => s + x.value, 0n);
|
|
357
|
+
if (data.paid + fee + protocol > data.maxFee)
|
|
358
|
+
fail('fee-too-high', `fees ${data.paid + fee + protocol} exceed ${data.maxFee}`);
|
|
359
|
+
const change = out(self, total - need, 'change');
|
|
360
|
+
let step: Step;
|
|
361
|
+
if (data.to.kind === 'public') {
|
|
362
|
+
step = {
|
|
363
|
+
inputs,
|
|
364
|
+
outputs: [out(relayerKey, fee, 'relayer'), change],
|
|
365
|
+
withdraw: { recipient: data.to.address, amount: withdrawn, unwrap: data.to.unwrap },
|
|
366
|
+
entropy: fresh(),
|
|
367
|
+
stage: 'planned',
|
|
368
|
+
attempts: 0,
|
|
369
|
+
};
|
|
370
|
+
} else {
|
|
371
|
+
const publicKey = data.to.kind === 'address' ? data.to.recipient.publicKey : codeOf(data.to.code).publicKey;
|
|
372
|
+
const recipient = out(bytesToHex(publicKey), op.amount, 'recipient');
|
|
373
|
+
step = {
|
|
374
|
+
inputs,
|
|
375
|
+
outputs: [recipient, change],
|
|
376
|
+
withdraw: { recipient: relayer, amount: withdrawn },
|
|
377
|
+
entropy: fresh(),
|
|
378
|
+
stage: 'planned',
|
|
379
|
+
attempts: 0,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return step;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether any of a consolidation's chosen notes and deposits (commitment indices) is still settling: not among the
|
|
386
|
+
* spendable `all` yet. Throws for one that never merges: a deposit the ASP rejected. A quote and every merge go by
|
|
387
|
+
* this one rule. */
|
|
388
|
+
export function chosenSettling(ctx: Ctx, chainId: bigint, chosen: number[], all: Coin[]): boolean {
|
|
389
|
+
const found = [
|
|
390
|
+
...ctx.state.notes
|
|
391
|
+
.filter((n) => n.chainId === chainId)
|
|
392
|
+
.flatMap((n) => n.found.map((f) => ({ ...f, amount: n.value, rejected: false }))),
|
|
393
|
+
...ctx.state.deposits
|
|
394
|
+
.filter((d) => d.chainId === chainId)
|
|
395
|
+
.flatMap((d) => d.found.map((f) => ({ ...f, rejected: f.asp === 'rejected' }))),
|
|
396
|
+
].filter((f) => chosen.includes(f.index) && !f.spent);
|
|
397
|
+
if (found.some((f) => f.rejected)) fail('invalid', 'a chosen deposit was rejected: only rage exit returns it');
|
|
398
|
+
return found.some((f) => !all.some((c) => c.index === f.index));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A change note of this operation that is neither spendable yet (final, confirmed and under the root of size
|
|
402
|
+
* `rootSize`) nor already spent by a later step. */
|
|
403
|
+
function hasPendingChange(ctx: Ctx, op: SpendOperation, rootSize: number): boolean {
|
|
404
|
+
const salts = new Set(op.data.changes ?? []);
|
|
405
|
+
const settled = (f: NoteRecord['found'][number]) => f.spent || (!!f.confirmed && f.index < rootSize);
|
|
406
|
+
return ctx.state.notes.some((n) => salts.has(n.salt) && !n.found.some(settled));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export const codeOf = (code: string) => decodeCode(code) ?? fail('invalid', 'not a receive code');
|
|
410
|
+
|
|
411
|
+
const openingOf = (op: SpendOperation, o: Output) => ({
|
|
412
|
+
chainId: op.chainId,
|
|
413
|
+
tokenId: op.tokenId,
|
|
414
|
+
value: bytes32(o.value),
|
|
415
|
+
noteSalt: hexToBytes(o.salt),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
/** What `step` paid besides `op`'s amount, the relayer's fee and the protocol's cut: what its inputs hold beyond the
|
|
419
|
+
* amount and the change. */
|
|
420
|
+
export const feeOf = (op: SpendOperation, step: Step): bigint =>
|
|
421
|
+
step.inputs.reduce((s, c) => s + c.value, 0n) - step.outputs.find((o) => o.role === 'change')!.value - op.amount;
|
|
422
|
+
|
|
423
|
+
/** What a payment or withdrawal was, for its change to carry to the wallet's other devices. */
|
|
424
|
+
function receiptOf(op: SpendOperation, step: Step): Receipt {
|
|
425
|
+
const to = op.data.to;
|
|
426
|
+
const [kind, counterparty]: [Receipt['kind'], Uint8Array] =
|
|
427
|
+
to.kind === 'address'
|
|
428
|
+
? ['sent', to.recipient.address]
|
|
429
|
+
: to.kind === 'code'
|
|
430
|
+
? ['sent-to-code', codeOf(to.code).burnAddress]
|
|
431
|
+
: to.kind === 'public'
|
|
432
|
+
? [to.unwrap ? 'unwrapped' : 'withdrawn', hexToBytes(to.address)]
|
|
433
|
+
: fail('invalid', 'a merge has no JoinSplit');
|
|
434
|
+
return { kind, amount: bytes32(op.amount), fee: bytes32(feeOf(op, step)), counterparty };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Writes the step's notes to storage before anything reaches the chain: the payment to its recipient and the
|
|
438
|
+
* change to the own lane, in one batch. The change, written even at zero value, carries the payment's receipt, which
|
|
439
|
+
* every device of the wallet shows once it lands; it is recorded here too, so it is found. */
|
|
440
|
+
async function writeNotes(ctx: Ctx, op: SpendOperation, step: Step): Promise<void> {
|
|
441
|
+
const data = op.data;
|
|
442
|
+
const opening = (o: Output) => openingOf(op, o);
|
|
443
|
+
const recipient = step.outputs.find((o) => o.role === 'recipient');
|
|
444
|
+
const change = step.outputs[1];
|
|
445
|
+
const receipt = receiptOf(op, step);
|
|
446
|
+
const result = await commit(ctx.storage, ctx.view, storageState(ctx), async (s) => [
|
|
447
|
+
...(recipient && data.to.kind === 'address' ? planSend(ctx.view, s, data.to.recipient, opening(recipient)) : []),
|
|
448
|
+
...(recipient && data.to.kind === 'code'
|
|
449
|
+
? await planPayCode(ctx.storage, ctx.view, s, codeOf(data.to.code), opening(recipient))
|
|
450
|
+
: []),
|
|
451
|
+
...planSelf(ctx.view, s, opening(change), receipt),
|
|
452
|
+
]);
|
|
453
|
+
takeRefresh(ctx, result, await tokenIds(ctx));
|
|
454
|
+
recordOwn(ctx, op, change, receipt);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Writes a merge's note to the own lane before its witness leaves, and records it, as a payment's change. */
|
|
458
|
+
async function writeMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Promise<void> {
|
|
459
|
+
const receipt: Receipt = {
|
|
460
|
+
kind: 'merged',
|
|
461
|
+
amount: bytes32(step.output.value),
|
|
462
|
+
fee: bytes32(step.fee),
|
|
463
|
+
counterparty: new Uint8Array(20),
|
|
464
|
+
};
|
|
465
|
+
const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) =>
|
|
466
|
+
planSelf(ctx.view, s, openingOf(op, step.output), receipt),
|
|
467
|
+
);
|
|
468
|
+
takeRefresh(ctx, result, await tokenIds(ctx));
|
|
469
|
+
recordOwn(ctx, op, step.output, receipt);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Records a note to self that `op` is making, so it is found once it lands. */
|
|
473
|
+
function recordOwn(ctx: Ctx, op: SpendOperation, o: Output, receipt: Receipt): void {
|
|
474
|
+
const from = commitmentsAt(ctx.state.chains.find((c) => c.chainId === op.chainId)?.samples ?? [], Date.now());
|
|
475
|
+
const noteHash = core.noteHash(op.chainId, o.publicKey, word(o.value), op.tokenId, o.salt) as Hex;
|
|
476
|
+
const record: NoteRecord = {
|
|
477
|
+
chainId: op.chainId,
|
|
478
|
+
tokenId: op.tokenId,
|
|
479
|
+
value: o.value,
|
|
480
|
+
salt: o.salt,
|
|
481
|
+
source: 'self',
|
|
482
|
+
receipt: noteReceipt(receipt),
|
|
483
|
+
peer: null,
|
|
484
|
+
atNs: null,
|
|
485
|
+
noteHash,
|
|
486
|
+
watch: { key: numberToHex(core.noteTag(op.tokenId, noteHash), { size: 4 }), from, scannedTo: from },
|
|
487
|
+
found: [],
|
|
488
|
+
};
|
|
489
|
+
if (!ctx.state.notes.some((n) => n.chainId === op.chainId && n.salt === o.salt)) ctx.state.notes.push(record);
|
|
490
|
+
op.data.changes = [...(op.data.changes ?? []), o.salt];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const keyOf = (ctx: Ctx, op: SpendOperation, c: Coin) =>
|
|
494
|
+
spendKey(ctx, c.keyIndex === undefined ? undefined : { chainId: op.chainId, tokenId: op.tokenId, index: c.keyIndex });
|
|
495
|
+
|
|
496
|
+
/** The attester's merge witness JSON (`backend/attester/README.md`) with each input's private key in place of its keys
|
|
497
|
+
* and signature, which `core.signMerge` puts in, and the public inputs it proves against: every input under one proved
|
|
498
|
+
* root, every deposit under one ASP root. `null` until a root is over them all. */
|
|
499
|
+
async function mergeWitnessOf(
|
|
500
|
+
ctx: Ctx,
|
|
501
|
+
op: SpendOperation,
|
|
502
|
+
step: MergeStep,
|
|
503
|
+
): Promise<{ witness: string; inputs: Record<string, string>; size: number } | null> {
|
|
504
|
+
const c = chainCtx(ctx, op.chainId);
|
|
505
|
+
const paths = await commitmentPaths(
|
|
506
|
+
c.indexer,
|
|
507
|
+
c.chain,
|
|
508
|
+
step.inputs.map((x) => x.index),
|
|
509
|
+
);
|
|
510
|
+
if (!paths) return null; // a root over the inputs is not proved yet
|
|
511
|
+
const burns = step.inputs.filter((x) => x.kind === 'burn');
|
|
512
|
+
const asps = await aspPaths(
|
|
513
|
+
c.asp,
|
|
514
|
+
c.chain,
|
|
515
|
+
burns.map((x) => x.index),
|
|
516
|
+
);
|
|
517
|
+
if (!asps) return null; // not in the ASP's published root yet
|
|
518
|
+
const asp = new Map(burns.map((x, i) => [x.index, asps[i]]));
|
|
519
|
+
// the root the paths fold to, not a second read that a new root could overtake
|
|
520
|
+
const aspRoot = asps[0]?.root ?? (await c.chain.latestAspRoot());
|
|
521
|
+
const zero = Array<string>(32).fill('0');
|
|
522
|
+
const witness = {
|
|
523
|
+
inputs: step.inputs.map((x, i) => {
|
|
524
|
+
const burn = x.kind === 'burn';
|
|
525
|
+
return {
|
|
526
|
+
is_burn: burn,
|
|
527
|
+
private_key: keyOf(ctx, op, x).secret,
|
|
528
|
+
commitment_index: `${x.index}`,
|
|
529
|
+
commitment_siblings: paths[i].siblings,
|
|
530
|
+
note_value: burn ? '0' : `${x.value}`,
|
|
531
|
+
note_salt: burn ? '0' : x.salt!,
|
|
532
|
+
burn_salt: burn ? x.burnSalt! : '0',
|
|
533
|
+
burn_from: burn ? x.sender! : '0',
|
|
534
|
+
burn_value: burn ? `${x.value}` : '0',
|
|
535
|
+
asp_siblings: asp.get(x.index)?.siblings ?? zero,
|
|
536
|
+
};
|
|
537
|
+
}),
|
|
538
|
+
out_public_key: step.output.publicKey,
|
|
539
|
+
out_salt: step.output.salt,
|
|
540
|
+
};
|
|
541
|
+
const inputs = {
|
|
542
|
+
tokenId: word(BigInt(op.tokenId)),
|
|
543
|
+
chainId: word(op.chainId),
|
|
544
|
+
commitmentRoot: paths[0].root,
|
|
545
|
+
aspRoot,
|
|
546
|
+
fee: word(step.fee),
|
|
547
|
+
feeRecipient: step.feeRecipient,
|
|
548
|
+
};
|
|
549
|
+
return { witness: JSON.stringify(witness), inputs, size: paths[0].size };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** `Verifier.NovaDeciderProof`, as the merge prover encodes it. */
|
|
553
|
+
const NOVA_PROOF = parseAbiParameters(
|
|
554
|
+
'(uint256 challenge, uint256[2] runningCmE, uint256[2] cmT, uint256[2] runningCmW, uint256[2] incomingCmW, uint256[12] proof)',
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
/** The JoinSplit input JSON (snarkjs's, which `core.signTransact` turns into the attester's), and the public inputs it
|
|
558
|
+
* starts from. */
|
|
559
|
+
async function witnessOf(
|
|
560
|
+
ctx: Ctx,
|
|
561
|
+
op: SpendOperation,
|
|
562
|
+
step: Step,
|
|
563
|
+
): Promise<{ witness: Record<string, unknown>; size: number } | null> {
|
|
564
|
+
const c = chainCtx(ctx, op.chainId);
|
|
565
|
+
const [in0, in1] = step.inputs as [Coin, Coin | undefined];
|
|
566
|
+
const p0 = await commitmentPath(c.indexer, c.chain, in0.index);
|
|
567
|
+
const p1 = in1 ? await commitmentPath(c.indexer, c.chain, in1.index) : null;
|
|
568
|
+
if (!p0 || (in1 && !p1)) return null; // a root over the input is not proved yet
|
|
569
|
+
if (p1 && p1.size !== p0.size) return null; // a root was proved in between: try again
|
|
570
|
+
const asp = in0.kind === 'burn' ? await aspPath(c.asp, c.chain, in0.index) : null;
|
|
571
|
+
if (in0.kind === 'burn' && !asp) return null; // not in the ASP's published root yet
|
|
572
|
+
// the root the path folds to, not a second read that a new root could overtake
|
|
573
|
+
const aspRoot = asp ? asp.root : await c.chain.latestAspRoot();
|
|
574
|
+
const zero = Array<string>(32).fill('0');
|
|
575
|
+
const key0 = keyOf(ctx, op, in0);
|
|
576
|
+
const key1 = in1 ? keyOf(ctx, op, in1) : null;
|
|
577
|
+
const [o1, o2] = step.outputs;
|
|
578
|
+
const burn = in0.kind === 'burn';
|
|
579
|
+
return {
|
|
580
|
+
size: p0.size,
|
|
581
|
+
witness: {
|
|
582
|
+
token_id: `${op.tokenId}`,
|
|
583
|
+
chain_id: `${op.chainId}`,
|
|
584
|
+
commitment_root: p0.root,
|
|
585
|
+
asp_root: aspRoot,
|
|
586
|
+
entropy: step.entropy,
|
|
587
|
+
in0_is_burn: burn ? '1' : '0',
|
|
588
|
+
in1_is_dummy: in1 ? '0' : '1',
|
|
589
|
+
in_note0_public_key: burn ? '0' : key0.publicKey,
|
|
590
|
+
in_note0_value: burn ? '0' : `${in0.value}`,
|
|
591
|
+
in_note0_salt: burn ? '0' : in0.salt!,
|
|
592
|
+
in_note1_public_key: key1 ? key1.publicKey : '0',
|
|
593
|
+
in_note1_value: in1 ? `${in1.value}` : '0',
|
|
594
|
+
in_note1_salt: in1 ? in1.salt! : '0',
|
|
595
|
+
burn_public_key: burn ? key0.publicKey : '0',
|
|
596
|
+
burn_salt: burn ? in0.burnSalt! : '0',
|
|
597
|
+
burn_transfer_from: burn ? in0.sender! : '0',
|
|
598
|
+
burn_transfer_value: burn ? `${in0.value}` : '0',
|
|
599
|
+
withdraw_recipient_private: step.withdraw ? BigInt(step.withdraw.recipient).toString() : '0',
|
|
600
|
+
withdraw_amount_private: step.withdraw ? `${step.withdraw.amount}` : '0',
|
|
601
|
+
withdraw_intent_private: step.withdraw?.unwrap ? `${UNWRAP_INTENT}` : '0',
|
|
602
|
+
out_note1_public_key: o1.publicKey,
|
|
603
|
+
out_note1_value: `${o1.value}`,
|
|
604
|
+
out_note1_salt: o1.salt,
|
|
605
|
+
out_note2_public_key: o2.publicKey,
|
|
606
|
+
out_note2_value: `${o2.value}`,
|
|
607
|
+
out_note2_salt: o2.salt,
|
|
608
|
+
private_key0: key0.secret,
|
|
609
|
+
private_key1: key1 ? key1.secret : '0',
|
|
610
|
+
auth_key_hash0: key0.authKeyHash,
|
|
611
|
+
auth_key_hash1: key1 ? key1.authKeyHash : '0',
|
|
612
|
+
commitment_index0: `${in0.index}`,
|
|
613
|
+
commitment_index1: in1 ? `${in1.index}` : '0',
|
|
614
|
+
in0_merkle_siblings: p0.siblings,
|
|
615
|
+
in1_merkle_siblings: p1 ? p1.siblings : zero,
|
|
616
|
+
in0_asp_siblings: asp ? asp.siblings : zero,
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** This wallet's records of the note or deposit at `index`: one, but for a tag that also matched a note it expects. */
|
|
622
|
+
const positionsAt = (ctx: Ctx, chainId: bigint, index: number): Position[] =>
|
|
623
|
+
[...ctx.state.notes, ...ctx.state.deposits]
|
|
624
|
+
.filter((r) => r.chainId === chainId)
|
|
625
|
+
.flatMap((r) => (r.found as Position[]).filter((f) => f.index === index));
|
|
626
|
+
|
|
627
|
+
/** The final one of them: an input is final. */
|
|
628
|
+
const positionOf = (ctx: Ctx, chainId: bigint, index: number): Position | undefined =>
|
|
629
|
+
positionsAt(ctx, chainId, index).find((f) => !f.tentative);
|
|
630
|
+
|
|
631
|
+
/** Whether the step's first input is spent on chain: among the final nullifiers (as sync last saw them), or among the
|
|
632
|
+
* newer ones. Nothing but the step spends a locked input (rage exit refuses one, and a wallet is open in one tab), so
|
|
633
|
+
* a spent input is the step landing. Reads the pages everyone polls, never asks about the nullifier. */
|
|
634
|
+
async function inputSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): Promise<boolean> {
|
|
635
|
+
const f = positionOf(ctx, op.chainId, step.inputs[0].index);
|
|
636
|
+
if (f?.spent) return true;
|
|
637
|
+
if (!f?.nullifier) return false;
|
|
638
|
+
const c = chainCtx(ctx, op.chainId);
|
|
639
|
+
const { nullifiers } = await c.indexer.status();
|
|
640
|
+
// a copy: sync alone moves the watch, over final entries
|
|
641
|
+
const watch = { ...f.nullifier };
|
|
642
|
+
const hits = await scan([watch], 8, (p) => c.indexer.page('nullifiers', p), watch.scannedTo, nullifiers);
|
|
643
|
+
return hits.length > 0;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Where a JoinSplit relayed when the indexer had `relayedAt` nullifiers stands, by its first input `f` as sync last
|
|
647
|
+
* saw it: spent, or the final nullifiers passed it without its own (a reorg dropped it, or the relayer never sent it),
|
|
648
|
+
* or neither yet. */
|
|
649
|
+
export function relayedState(f: Position | undefined, relayedAt: number): 'landed' | 'dropped' | 'waiting' {
|
|
650
|
+
if (f?.spent) return 'landed';
|
|
651
|
+
return f?.nullifier && f.nullifier.scannedTo >= relayedAt ? 'dropped' : 'waiting';
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Whether `op`'s payment or withdrawal is on chain: done, or relayed, which the relayer answers once it is mined. Until
|
|
655
|
+
* it is final a reorg may still drop it, and the operation relays it again. */
|
|
656
|
+
export function landed(op: SpendOperation): boolean {
|
|
657
|
+
return (
|
|
658
|
+
op.status === 'done' || (op.status === 'pending' && op.kind !== 'consolidate' && op.data.step?.stage === 'relayed')
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function markSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): void {
|
|
663
|
+
for (const coin of step.inputs) for (const f of positionsAt(ctx, op.chainId, coin.index)) f.spent = true;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** A note to self's position (its `salt`'s) as the relayer reported it, until sync finds it final. */
|
|
667
|
+
function placeChange(ctx: Ctx, op: SpendOperation, salt: Hex, index: number | null): void {
|
|
668
|
+
const note = ctx.state.notes.find((n) => n.chainId === op.chainId && n.salt === salt);
|
|
669
|
+
if (note && index !== null && !note.found.some((f) => f.index === index)) {
|
|
670
|
+
note.found.push({ index, tentative: true, spent: false });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Takes `op` as far as it can go now, saving before each step that leaves the device. */
|
|
675
|
+
export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void> {
|
|
676
|
+
const data = op.data;
|
|
677
|
+
const c = chainCtx(ctx, op.chainId);
|
|
678
|
+
for (;;) {
|
|
679
|
+
if (!data.step) {
|
|
680
|
+
const step = await planStep(ctx, op);
|
|
681
|
+
if (!step) return;
|
|
682
|
+
if (step === 'done') {
|
|
683
|
+
op.status = 'done';
|
|
684
|
+
await ctx.save();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
data.step = step;
|
|
688
|
+
await ctx.save();
|
|
689
|
+
}
|
|
690
|
+
if ('output' in data.step) {
|
|
691
|
+
if (!(await advanceMerge(ctx, op, data.step))) return;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const step = data.step;
|
|
695
|
+
if (step.stage === 'planned') {
|
|
696
|
+
await writeNotes(ctx, op, step);
|
|
697
|
+
step.stage = 'noted';
|
|
698
|
+
await ctx.save();
|
|
699
|
+
}
|
|
700
|
+
if (step.stage === 'noted') {
|
|
701
|
+
const built = await witnessOf(ctx, op, step);
|
|
702
|
+
if (!built) return;
|
|
703
|
+
const inputs = {
|
|
704
|
+
tokenId: word(BigInt(op.tokenId)),
|
|
705
|
+
chainId: word(op.chainId),
|
|
706
|
+
commitmentRoot: built.witness.commitment_root,
|
|
707
|
+
aspRoot: built.witness.asp_root,
|
|
708
|
+
};
|
|
709
|
+
// the attester's own check, run here first: a witness it would refuse never leaves the device. What it is sent
|
|
710
|
+
// holds no private key, only what the keys derive and the owners' signatures on this statement.
|
|
711
|
+
let statement: Record<string, string>;
|
|
712
|
+
let witness: string;
|
|
713
|
+
try {
|
|
714
|
+
const signed = JSON.parse(core.signTransact(JSON.stringify(inputs), JSON.stringify(built.witness))) as {
|
|
715
|
+
statement: Record<string, string>;
|
|
716
|
+
witness: unknown;
|
|
717
|
+
};
|
|
718
|
+
statement = signed.statement;
|
|
719
|
+
witness = JSON.stringify(signed.witness);
|
|
720
|
+
} catch (e) {
|
|
721
|
+
return fail('invalid', `the attester would refuse this witness: ${(e as Error).message}`);
|
|
722
|
+
}
|
|
723
|
+
const { proof, signals } = await prove(ctx.network.circuits.joinsplit, built.witness);
|
|
724
|
+
const order = [
|
|
725
|
+
'nullifier0',
|
|
726
|
+
'nullifier1',
|
|
727
|
+
'withdrawRecipient',
|
|
728
|
+
'withdrawAmount',
|
|
729
|
+
'withdrawIntent',
|
|
730
|
+
'noteHash1',
|
|
731
|
+
'noteHash2',
|
|
732
|
+
'tokenId',
|
|
733
|
+
'chainId',
|
|
734
|
+
'commitmentRoot',
|
|
735
|
+
'aspRoot',
|
|
736
|
+
];
|
|
737
|
+
if (order.some((k, i) => BigInt(statement[k]) !== signals[i]))
|
|
738
|
+
fail('invalid', 'the proof and the statement disagree');
|
|
739
|
+
step.witness = witness;
|
|
740
|
+
step.statement = statement;
|
|
741
|
+
step.request = { rootIndex: built.size, proof, publicSignals: statement };
|
|
742
|
+
step.stage = 'proved';
|
|
743
|
+
await ctx.save();
|
|
744
|
+
}
|
|
745
|
+
if (step.stage === 'proved') {
|
|
746
|
+
const attestation = await attest(c.config, step.statement!, step.witness!);
|
|
747
|
+
step.request = { ...step.request!, attestation };
|
|
748
|
+
delete step.witness; // it holds the inputs' openings; the attestation is all that is needed of it now
|
|
749
|
+
step.stage = 'attested';
|
|
750
|
+
await ctx.save();
|
|
751
|
+
}
|
|
752
|
+
if (step.stage === 'attested') {
|
|
753
|
+
const relayerOut = step.outputs.find((o) => o.role === 'relayer');
|
|
754
|
+
let result: Relayed | { reverted: string };
|
|
755
|
+
try {
|
|
756
|
+
result = await relay(
|
|
757
|
+
c.config,
|
|
758
|
+
step.request!,
|
|
759
|
+
relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined,
|
|
760
|
+
);
|
|
761
|
+
} catch (e) {
|
|
762
|
+
if (e instanceof RelayUnknown) return; // try the same request again next time
|
|
763
|
+
throw e;
|
|
764
|
+
}
|
|
765
|
+
if ('reverted' in result) {
|
|
766
|
+
if (!(await inputSpent(ctx, op, step))) {
|
|
767
|
+
// stale roots, most likely: prove again with the same outputs, next sync. A JoinSplit that keeps reverting
|
|
768
|
+
// is not on chain and cannot land, so failing it frees its inputs.
|
|
769
|
+
if (++step.attempts > 3)
|
|
770
|
+
fail(
|
|
771
|
+
/the fee is/.test(result.reverted) ? 'fee-too-high' : 'invalid',
|
|
772
|
+
`the transaction keeps reverting: ${result.reverted}`,
|
|
773
|
+
);
|
|
774
|
+
step.stage = 'noted';
|
|
775
|
+
await ctx.save();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
// an earlier relay of it landed, one whose answer was lost
|
|
779
|
+
} else {
|
|
780
|
+
placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
|
|
781
|
+
data.txHashes = [...(data.txHashes ?? []), result.txHash];
|
|
782
|
+
}
|
|
783
|
+
step.relayedAt = (await c.indexer.status()).nullifiers;
|
|
784
|
+
step.stage = 'relayed';
|
|
785
|
+
await ctx.save();
|
|
786
|
+
}
|
|
787
|
+
if (step.stage === 'relayed') {
|
|
788
|
+
const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt!);
|
|
789
|
+
if (now === 'dropped') {
|
|
790
|
+
// relay it again next sync; if it did land after all, that reverts and finds it
|
|
791
|
+
step.stage = 'attested';
|
|
792
|
+
await ctx.save();
|
|
793
|
+
}
|
|
794
|
+
if (now !== 'landed') return;
|
|
795
|
+
markSpent(ctx, op, step);
|
|
796
|
+
data.paid += feeOf(op, step);
|
|
797
|
+
data.step = null;
|
|
798
|
+
op.status = 'done';
|
|
799
|
+
await ctx.save();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Takes a consolidation's merge as far as it can go now, saving before each step that leaves the device. True once
|
|
806
|
+
* it landed, so the next merge can be planned. */
|
|
807
|
+
async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Promise<boolean> {
|
|
808
|
+
const data = op.data;
|
|
809
|
+
const c = chainCtx(ctx, op.chainId);
|
|
810
|
+
const prover = ctx.network.mergeProver ?? fail('invalid', 'this network has no merge prover');
|
|
811
|
+
// built again from its inputs, after a refusal or a revert: the same output, so the same note in storage
|
|
812
|
+
const again = async (why: string) => {
|
|
813
|
+
if (++step.attempts > 3)
|
|
814
|
+
fail(/the fee is/.test(why) ? 'fee-too-high' : 'invalid', `the merge keeps failing: ${why}`);
|
|
815
|
+
step.stage = 'noted';
|
|
816
|
+
await ctx.save();
|
|
817
|
+
return false;
|
|
818
|
+
};
|
|
819
|
+
if (step.stage === 'planned') {
|
|
820
|
+
await writeMerge(ctx, op, step);
|
|
821
|
+
step.stage = 'noted';
|
|
822
|
+
await ctx.save();
|
|
823
|
+
}
|
|
824
|
+
if (step.stage === 'noted') {
|
|
825
|
+
const built = await mergeWitnessOf(ctx, op, step);
|
|
826
|
+
if (!built) return false;
|
|
827
|
+
// the attester's own check, run here first: a witness it would refuse never leaves the device. What it and the merge
|
|
828
|
+
// prover are sent holds no private key, only what the keys derive and the owners' signatures on this merge.
|
|
829
|
+
let statement: Record<string, unknown>;
|
|
830
|
+
let witness: string;
|
|
831
|
+
try {
|
|
832
|
+
const signed = JSON.parse(core.signMerge(JSON.stringify(built.inputs), built.witness)) as {
|
|
833
|
+
statement: Record<string, unknown>;
|
|
834
|
+
witness: unknown;
|
|
835
|
+
};
|
|
836
|
+
statement = signed.statement;
|
|
837
|
+
witness = JSON.stringify(signed.witness);
|
|
838
|
+
} catch (e) {
|
|
839
|
+
return fail('invalid', `the attester would refuse this merge: ${(e as Error).message}`);
|
|
840
|
+
}
|
|
841
|
+
const attestation = await attest(c.config, statement, witness, step.inputs.length);
|
|
842
|
+
const { tokenId, commitmentRoot, aspRoot, noteHash, fee, feeRecipient, nullifiers } = statement;
|
|
843
|
+
step.witness = witness;
|
|
844
|
+
step.statement = statement;
|
|
845
|
+
step.request = {
|
|
846
|
+
rootIndex: built.size,
|
|
847
|
+
tokenId,
|
|
848
|
+
commitmentRoot,
|
|
849
|
+
aspRoot,
|
|
850
|
+
noteHash,
|
|
851
|
+
fee,
|
|
852
|
+
feeRecipient,
|
|
853
|
+
nullifiers,
|
|
854
|
+
attestation,
|
|
855
|
+
};
|
|
856
|
+
step.stage = 'attested';
|
|
857
|
+
await ctx.save();
|
|
858
|
+
}
|
|
859
|
+
if (step.stage === 'attested') {
|
|
860
|
+
await requestProof(c.config, prover, step.statement!, step.witness!, step.inputs.length);
|
|
861
|
+
delete step.witness; // the prover has it: the inputs' openings need not stay saved here
|
|
862
|
+
step.stage = 'proving';
|
|
863
|
+
await ctx.save();
|
|
864
|
+
}
|
|
865
|
+
if (step.stage === 'proving') {
|
|
866
|
+
const job = await proofOf(prover, step.statement!.noteHash as Hex);
|
|
867
|
+
if (job?.status === 'queued' || job?.status === 'proving') return false;
|
|
868
|
+
if (!job) {
|
|
869
|
+
// the prover lost it in a restart: built and sent again, which is no fault of the merge's
|
|
870
|
+
step.stage = 'noted';
|
|
871
|
+
await ctx.save();
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
if (job.status !== 'done') return again(job.error ?? 'the merge prover refused it');
|
|
875
|
+
const [proof] = decodeAbiParameters(NOVA_PROOF, job.proof!);
|
|
876
|
+
step.request = { ...step.request!, proof };
|
|
877
|
+
step.stage = 'proved';
|
|
878
|
+
await ctx.save();
|
|
879
|
+
}
|
|
880
|
+
if (step.stage === 'proved') {
|
|
881
|
+
let result: Awaited<ReturnType<typeof relayMerge>>;
|
|
882
|
+
try {
|
|
883
|
+
result = await relayMerge(c.config, step.request!);
|
|
884
|
+
} catch (e) {
|
|
885
|
+
if (e instanceof RelayUnknown) return false; // try the same request again next time
|
|
886
|
+
throw e;
|
|
887
|
+
}
|
|
888
|
+
if ('reverted' in result) {
|
|
889
|
+
// stale roots, most likely: built again with the same output, next sync
|
|
890
|
+
if (!(await inputSpent(ctx, op, step))) return again(result.reverted);
|
|
891
|
+
// an earlier relay of it landed, one whose answer was lost
|
|
892
|
+
} else {
|
|
893
|
+
placeChange(ctx, op, step.output.salt, result.noteIndex);
|
|
894
|
+
data.txHashes = [...(data.txHashes ?? []), result.txHash];
|
|
895
|
+
}
|
|
896
|
+
step.relayedAt = (await c.indexer.status()).nullifiers;
|
|
897
|
+
step.stage = 'relayed';
|
|
898
|
+
await ctx.save();
|
|
899
|
+
}
|
|
900
|
+
const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt!);
|
|
901
|
+
if (now === 'dropped') {
|
|
902
|
+
// relay it again next sync; if it did land after all, that reverts and finds it
|
|
903
|
+
step.stage = 'proved';
|
|
904
|
+
await ctx.save();
|
|
905
|
+
}
|
|
906
|
+
if (now !== 'landed') return false;
|
|
907
|
+
markSpent(ctx, op, step);
|
|
908
|
+
data.paid += step.fee;
|
|
909
|
+
data.step = null;
|
|
910
|
+
await ctx.save();
|
|
911
|
+
return true;
|
|
912
|
+
}
|