@lethenymous/sdk 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +171 -2
- package/dist/accounts.d.ts +6 -0
- package/dist/accounts.js +75 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.js +357 -0
- package/dist/crypto.d.ts +24 -0
- package/dist/crypto.js +111 -0
- package/dist/encoding.d.ts +26 -0
- package/dist/encoding.js +41 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/instructions.d.ts +10 -0
- package/dist/instructions.js +78 -0
- package/dist/math.d.ts +18 -0
- package/dist/math.js +76 -0
- package/dist/merkle.d.ts +4 -0
- package/dist/merkle.js +10 -0
- package/dist/pda.d.ts +18 -0
- package/dist/pda.js +21 -0
- package/dist/prover.d.ts +26 -0
- package/dist/prover.js +177 -0
- package/dist/store.d.ts +39 -0
- package/dist/store.js +662 -0
- package/dist/types.d.ts +203 -0
- package/dist/types.js +1 -0
- package/dist/wallet.d.ts +73 -0
- package/dist/wallet.js +491 -0
- package/dist/witness.d.ts +71 -0
- package/dist/witness.js +872 -0
- package/package.json +48 -4
package/dist/witness.js
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { PublicKey } from "@solana/web3.js";
|
|
3
|
+
import { hash2 } from "./crypto.js";
|
|
4
|
+
import { ROOT_HISTORY, TREE_CAPACITY, TREE_DEPTH, rootFromTree, verifyPath } from "./merkle.js";
|
|
5
|
+
const eventId = (name) => createHash("sha256").update(`event:${name}`).digest().subarray(0, 8);
|
|
6
|
+
const SHIELD = eventId("ShieldedNoteAppended");
|
|
7
|
+
const SWAP = eventId("PrivateSwapped");
|
|
8
|
+
const UNSHIELD = eventId("Unshielded");
|
|
9
|
+
const BN254_MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
10
|
+
const CHECKPOINT_VERSION = 1;
|
|
11
|
+
const same = (a, b) => Buffer.from(a).equals(Buffer.from(b));
|
|
12
|
+
const hex = (value) => Buffer.from(value).toString("hex");
|
|
13
|
+
const bytes = (value) => Uint8Array.from(Buffer.from(value, "hex"));
|
|
14
|
+
const cloneBytes = (value) => Uint8Array.from(value);
|
|
15
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
16
|
+
const u64 = (data, offset) => data.readBigUInt64LE(offset);
|
|
17
|
+
const pubkey = (data, offset) => new PublicKey(data.subarray(offset, offset + 32));
|
|
18
|
+
const canonicalField = (value) => BigInt(`0x${Buffer.from(value).toString("hex")}`) < BN254_MODULUS;
|
|
19
|
+
export class MerkleReconstructionError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.name = "MerkleReconstructionError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function reconstruction(code, message) {
|
|
28
|
+
throw new MerkleReconstructionError(code, message);
|
|
29
|
+
}
|
|
30
|
+
function cloneTree(tree) {
|
|
31
|
+
return {
|
|
32
|
+
pool: new PublicKey(tree.pool),
|
|
33
|
+
generation: tree.generation,
|
|
34
|
+
nextIndex: tree.nextIndex,
|
|
35
|
+
sequence: tree.sequence,
|
|
36
|
+
frontier: tree.frontier.map(cloneBytes),
|
|
37
|
+
frontierPresent: [...tree.frontierPresent],
|
|
38
|
+
emptySubtrees: tree.emptySubtrees.map(cloneBytes),
|
|
39
|
+
roots: tree.roots.map(cloneBytes),
|
|
40
|
+
rootSequences: [...tree.rootSequences],
|
|
41
|
+
rootGenerations: [...tree.rootGenerations],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function cloneEvent(event) {
|
|
45
|
+
if (event.kind === "shield") {
|
|
46
|
+
return {
|
|
47
|
+
...event,
|
|
48
|
+
pool: new PublicKey(event.pool),
|
|
49
|
+
commitment: cloneBytes(event.commitment),
|
|
50
|
+
encryptedNote: cloneBytes(event.encryptedNote),
|
|
51
|
+
root: cloneBytes(event.root),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
...event,
|
|
56
|
+
pool: new PublicKey(event.pool),
|
|
57
|
+
root: cloneBytes(event.root),
|
|
58
|
+
nullifier: cloneBytes(event.nullifier),
|
|
59
|
+
changeCommitment: cloneBytes(event.changeCommitment),
|
|
60
|
+
outputCommitment: cloneBytes(event.outputCommitment),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function cloneState(state) {
|
|
64
|
+
return {
|
|
65
|
+
identity: { ...state.identity },
|
|
66
|
+
tree: cloneTree(state.tree),
|
|
67
|
+
appends: new Map([...state.appends].map(([index, record]) => [index, {
|
|
68
|
+
index: record.index,
|
|
69
|
+
sequence: record.sequence,
|
|
70
|
+
commitment: cloneBytes(record.commitment),
|
|
71
|
+
event: cloneEvent(record.event),
|
|
72
|
+
}])),
|
|
73
|
+
spentNullifiers: new Set(state.spentNullifiers),
|
|
74
|
+
lastProcessedSignature: state.lastProcessedSignature,
|
|
75
|
+
lastFinalizedSlot: state.lastFinalizedSlot,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function parseBytes(value, length, name) {
|
|
79
|
+
if (typeof value !== "string" || !/^[0-9a-f]+$/i.test(value) || value.length !== length * 2)
|
|
80
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid ${name} in Merkle checkpoint`);
|
|
81
|
+
return bytes(value);
|
|
82
|
+
}
|
|
83
|
+
function parseBigInt(value, name) {
|
|
84
|
+
if (typeof value !== "string" || !/^\d+$/.test(value))
|
|
85
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid ${name} in Merkle checkpoint`);
|
|
86
|
+
try {
|
|
87
|
+
return BigInt(value);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid ${name} in Merkle checkpoint`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function parseSafeInteger(value, name, minimum = 0) {
|
|
94
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum)
|
|
95
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid ${name} in Merkle checkpoint`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function parsePublicKey(value, name) {
|
|
99
|
+
try {
|
|
100
|
+
if (typeof value !== "string")
|
|
101
|
+
throw new Error();
|
|
102
|
+
return new PublicKey(value);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid ${name} in Merkle checkpoint`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function serializeEvent(event) {
|
|
109
|
+
if (event.kind === "shield") {
|
|
110
|
+
return {
|
|
111
|
+
kind: event.kind,
|
|
112
|
+
pool: event.pool.toBase58(),
|
|
113
|
+
asset: event.asset,
|
|
114
|
+
amount: event.amount.toString(),
|
|
115
|
+
commitment: hex(event.commitment),
|
|
116
|
+
encryptedNote: hex(event.encryptedNote),
|
|
117
|
+
root: hex(event.root),
|
|
118
|
+
generation: event.generation.toString(),
|
|
119
|
+
index: event.index.toString(),
|
|
120
|
+
sequence: event.sequence.toString(),
|
|
121
|
+
slot: event.slot,
|
|
122
|
+
signature: event.signature,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
kind: event.kind,
|
|
127
|
+
pool: event.pool.toBase58(),
|
|
128
|
+
direction: event.direction,
|
|
129
|
+
amountIn: event.amountIn.toString(),
|
|
130
|
+
amountOut: event.amountOut.toString(),
|
|
131
|
+
root: hex(event.root),
|
|
132
|
+
rootSequence: event.rootSequence.toString(),
|
|
133
|
+
generation: event.generation.toString(),
|
|
134
|
+
nullifier: hex(event.nullifier),
|
|
135
|
+
changeCommitment: hex(event.changeCommitment),
|
|
136
|
+
outputCommitment: hex(event.outputCommitment),
|
|
137
|
+
changeIndex: event.changeIndex?.toString(),
|
|
138
|
+
outputIndex: event.outputIndex.toString(),
|
|
139
|
+
slot: event.slot,
|
|
140
|
+
signature: event.signature,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function deserializeEvent(value) {
|
|
144
|
+
if (!value || typeof value !== "object")
|
|
145
|
+
reconstruction("INVALID_CHECKPOINT", "Invalid append event in Merkle checkpoint");
|
|
146
|
+
const event = value;
|
|
147
|
+
if (event.kind === "shield") {
|
|
148
|
+
return {
|
|
149
|
+
kind: "shield",
|
|
150
|
+
pool: parsePublicKey(event.pool, "shield pool"),
|
|
151
|
+
asset: parseSafeInteger(event.asset, "shield asset"),
|
|
152
|
+
amount: parseBigInt(event.amount, "shield amount"),
|
|
153
|
+
commitment: parseBytes(event.commitment, 32, "shield commitment"),
|
|
154
|
+
encryptedNote: parseBytes(event.encryptedNote, 186, "encrypted note"),
|
|
155
|
+
root: parseBytes(event.root, 32, "shield root"),
|
|
156
|
+
generation: parseBigInt(event.generation, "shield generation"),
|
|
157
|
+
index: parseBigInt(event.index, "shield index"),
|
|
158
|
+
sequence: parseBigInt(event.sequence, "shield sequence"),
|
|
159
|
+
slot: parseSafeInteger(event.slot, "shield slot"),
|
|
160
|
+
signature: typeof event.signature === "string" && event.signature.length > 0 ? event.signature : reconstruction("INVALID_CHECKPOINT", "Invalid shield signature in Merkle checkpoint"),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (event.kind === "swap") {
|
|
164
|
+
return {
|
|
165
|
+
kind: "swap",
|
|
166
|
+
pool: parsePublicKey(event.pool, "swap pool"),
|
|
167
|
+
direction: parseSafeInteger(event.direction, "swap direction"),
|
|
168
|
+
amountIn: parseBigInt(event.amountIn, "swap amountIn"),
|
|
169
|
+
amountOut: parseBigInt(event.amountOut, "swap amountOut"),
|
|
170
|
+
root: parseBytes(event.root, 32, "swap root"),
|
|
171
|
+
rootSequence: parseBigInt(event.rootSequence, "swap root sequence"),
|
|
172
|
+
generation: parseBigInt(event.generation, "swap generation"),
|
|
173
|
+
nullifier: parseBytes(event.nullifier, 32, "swap nullifier"),
|
|
174
|
+
changeCommitment: parseBytes(event.changeCommitment, 32, "swap change commitment"),
|
|
175
|
+
outputCommitment: parseBytes(event.outputCommitment, 32, "swap output commitment"),
|
|
176
|
+
changeIndex: event.changeIndex === undefined ? undefined : parseBigInt(event.changeIndex, "swap change index"),
|
|
177
|
+
outputIndex: parseBigInt(event.outputIndex, "swap output index"),
|
|
178
|
+
slot: parseSafeInteger(event.slot, "swap slot"),
|
|
179
|
+
signature: typeof event.signature === "string" && event.signature.length > 0 ? event.signature : reconstruction("INVALID_CHECKPOINT", "Invalid swap signature in Merkle checkpoint"),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
reconstruction("INVALID_CHECKPOINT", "Unknown append event in Merkle checkpoint");
|
|
183
|
+
}
|
|
184
|
+
function serializeTree(tree) {
|
|
185
|
+
return {
|
|
186
|
+
pool: tree.pool.toBase58(),
|
|
187
|
+
generation: tree.generation.toString(),
|
|
188
|
+
nextIndex: tree.nextIndex.toString(),
|
|
189
|
+
sequence: tree.sequence.toString(),
|
|
190
|
+
frontier: tree.frontier.map(hex),
|
|
191
|
+
frontierPresent: [...tree.frontierPresent],
|
|
192
|
+
emptySubtrees: tree.emptySubtrees.map(hex),
|
|
193
|
+
roots: tree.roots.map(hex),
|
|
194
|
+
rootSequences: tree.rootSequences.map(value => value.toString()),
|
|
195
|
+
rootGenerations: tree.rootGenerations.map(value => value.toString()),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function deserializeTree(value) {
|
|
199
|
+
if (!value || typeof value !== "object")
|
|
200
|
+
reconstruction("INVALID_CHECKPOINT", "Invalid tree state in Merkle checkpoint");
|
|
201
|
+
const tree = value;
|
|
202
|
+
const array = (name) => {
|
|
203
|
+
const item = tree[name];
|
|
204
|
+
if (!Array.isArray(item))
|
|
205
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid ${name} in Merkle checkpoint`);
|
|
206
|
+
return item;
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
pool: parsePublicKey(tree.pool, "tree pool"),
|
|
210
|
+
generation: parseBigInt(tree.generation, "tree generation"),
|
|
211
|
+
nextIndex: parseBigInt(tree.nextIndex, "tree next index"),
|
|
212
|
+
sequence: parseBigInt(tree.sequence, "tree sequence"),
|
|
213
|
+
frontier: array("frontier").map((item, index) => parseBytes(item, 32, `frontier[${index}]`)),
|
|
214
|
+
frontierPresent: array("frontierPresent").map((item, index) => parseSafeInteger(item, `frontierPresent[${index}]`)),
|
|
215
|
+
emptySubtrees: array("emptySubtrees").map((item, index) => parseBytes(item, 32, `emptySubtrees[${index}]`)),
|
|
216
|
+
roots: array("roots").map((item, index) => parseBytes(item, 32, `roots[${index}]`)),
|
|
217
|
+
rootSequences: array("rootSequences").map((item, index) => parseBigInt(item, `rootSequences[${index}]`)),
|
|
218
|
+
rootGenerations: array("rootGenerations").map((item, index) => parseBigInt(item, `rootGenerations[${index}]`)),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function identityObject(identity) {
|
|
222
|
+
return {
|
|
223
|
+
genesisHash: identity.genesisHash,
|
|
224
|
+
programId: identity.programId,
|
|
225
|
+
pool: identity.pool,
|
|
226
|
+
tree: identity.tree,
|
|
227
|
+
generation: identity.generation.toString(),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function identityKey(identity) {
|
|
231
|
+
return JSON.stringify(identityObject(identity));
|
|
232
|
+
}
|
|
233
|
+
function validateTreeShape(tree, context) {
|
|
234
|
+
if (tree.frontier.length !== TREE_DEPTH || tree.frontierPresent.length !== TREE_DEPTH || tree.emptySubtrees.length !== TREE_DEPTH + 1 || tree.roots.length !== ROOT_HISTORY || tree.rootSequences.length !== ROOT_HISTORY || tree.rootGenerations.length !== ROOT_HISTORY)
|
|
235
|
+
reconstruction("INVALID_TREE", `Invalid ${context} array lengths`);
|
|
236
|
+
if (tree.nextIndex < 0n || tree.nextIndex > TREE_CAPACITY || tree.sequence < 0n || tree.sequence !== tree.nextIndex)
|
|
237
|
+
reconstruction("INVALID_TREE", `Invalid ${context} counters`);
|
|
238
|
+
if (tree.frontier.some(value => value.length !== 32) || tree.emptySubtrees.some(value => value.length !== 32) || tree.roots.some(value => value.length !== 32))
|
|
239
|
+
reconstruction("INVALID_TREE", `Invalid ${context} node length`);
|
|
240
|
+
if (tree.frontier.some(value => !canonicalField(value)) || tree.emptySubtrees.some(value => !canonicalField(value)) || tree.roots.some(value => !canonicalField(value)))
|
|
241
|
+
reconstruction("INVALID_TREE", `Invalid ${context} field element`);
|
|
242
|
+
if (tree.frontierPresent.some(value => value !== 0 && value !== 1))
|
|
243
|
+
reconstruction("INVALID_TREE", `Invalid ${context} frontier flags`);
|
|
244
|
+
let root;
|
|
245
|
+
try {
|
|
246
|
+
root = rootFromTree(tree);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
reconstruction("INVALID_TREE", `${context} root is invalid: ${String(error)}`);
|
|
250
|
+
}
|
|
251
|
+
const slot = Number(tree.sequence % BigInt(ROOT_HISTORY));
|
|
252
|
+
if (tree.rootSequences[slot] !== tree.sequence || tree.rootGenerations[slot] !== tree.generation || !same(root, tree.roots[slot]))
|
|
253
|
+
reconstruction("INVALID_TREE", `${context} root history is inconsistent`);
|
|
254
|
+
}
|
|
255
|
+
function cloneInitialTree(pool, current) {
|
|
256
|
+
const zero = new Uint8Array(32);
|
|
257
|
+
const tree = {
|
|
258
|
+
pool: new PublicKey(pool),
|
|
259
|
+
generation: current.generation,
|
|
260
|
+
nextIndex: 0n,
|
|
261
|
+
sequence: 0n,
|
|
262
|
+
frontier: Array.from({ length: TREE_DEPTH }, () => cloneBytes(zero)),
|
|
263
|
+
frontierPresent: Array(TREE_DEPTH).fill(0),
|
|
264
|
+
emptySubtrees: current.emptySubtrees.map(cloneBytes),
|
|
265
|
+
roots: Array.from({ length: ROOT_HISTORY }, () => cloneBytes(zero)),
|
|
266
|
+
rootSequences: Array(ROOT_HISTORY).fill(0n),
|
|
267
|
+
rootGenerations: Array(ROOT_HISTORY).fill(0n),
|
|
268
|
+
};
|
|
269
|
+
tree.roots[0] = cloneBytes(tree.emptySubtrees[TREE_DEPTH]);
|
|
270
|
+
tree.rootGenerations[0] = current.generation;
|
|
271
|
+
return tree;
|
|
272
|
+
}
|
|
273
|
+
function rootAt(tree, sequence) {
|
|
274
|
+
if (sequence > tree.sequence || tree.sequence - sequence >= BigInt(ROOT_HISTORY))
|
|
275
|
+
return undefined;
|
|
276
|
+
const slot = Number(sequence % BigInt(ROOT_HISTORY));
|
|
277
|
+
if (tree.rootSequences[slot] !== sequence || tree.rootGenerations[slot] !== tree.generation)
|
|
278
|
+
return undefined;
|
|
279
|
+
return tree.roots[slot];
|
|
280
|
+
}
|
|
281
|
+
function assertAcceptedRoot(tree, sequence, root) {
|
|
282
|
+
const accepted = rootAt(tree, sequence);
|
|
283
|
+
if (!accepted || (root && !same(accepted, root)))
|
|
284
|
+
reconstruction("ROOT_MISMATCH", `Event references an unavailable finalized root sequence ${sequence}`);
|
|
285
|
+
}
|
|
286
|
+
function appendLeaf(tree, leaf) {
|
|
287
|
+
if (leaf.length !== 32 || !canonicalField(leaf) || same(leaf, new Uint8Array(32)))
|
|
288
|
+
reconstruction("SEQUENCE_GAP", "Invalid Merkle append commitment");
|
|
289
|
+
if (tree.nextIndex >= TREE_CAPACITY)
|
|
290
|
+
reconstruction("SEQUENCE_GAP", "Merkle tree is full");
|
|
291
|
+
const index = tree.nextIndex;
|
|
292
|
+
let carry = cloneBytes(leaf);
|
|
293
|
+
for (let level = 0; level < TREE_DEPTH; level++) {
|
|
294
|
+
if (((index >> BigInt(level)) & 1n) === 0n) {
|
|
295
|
+
tree.frontier[level] = carry;
|
|
296
|
+
tree.frontierPresent[level] = 1;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
if (tree.frontierPresent[level] !== 1)
|
|
300
|
+
reconstruction("INVALID_TREE", `Missing frontier node at level ${level}`);
|
|
301
|
+
carry = Uint8Array.from(hash2(tree.frontier[level], carry));
|
|
302
|
+
tree.frontierPresent[level] = 0;
|
|
303
|
+
}
|
|
304
|
+
if (index === TREE_CAPACITY - 1n) {
|
|
305
|
+
tree.frontier[TREE_DEPTH - 1] = carry;
|
|
306
|
+
tree.frontierPresent[TREE_DEPTH - 1] = 1;
|
|
307
|
+
}
|
|
308
|
+
tree.nextIndex += 1n;
|
|
309
|
+
tree.sequence += 1n;
|
|
310
|
+
const root = rootFromTree(tree);
|
|
311
|
+
const slot = Number(tree.sequence % BigInt(ROOT_HISTORY));
|
|
312
|
+
tree.roots[slot] = cloneBytes(root);
|
|
313
|
+
tree.rootSequences[slot] = tree.sequence;
|
|
314
|
+
tree.rootGenerations[slot] = tree.generation;
|
|
315
|
+
return { index, sequence: tree.sequence, root: cloneBytes(root) };
|
|
316
|
+
}
|
|
317
|
+
function assertIdentity(pool, event) {
|
|
318
|
+
if (!event.pool.equals(pool))
|
|
319
|
+
reconstruction("GENERATION_MISMATCH", "History event references another pool");
|
|
320
|
+
}
|
|
321
|
+
function appendRecord(state, result, commitment, event) {
|
|
322
|
+
if (state.appends.has(result.index))
|
|
323
|
+
reconstruction("DUPLICATE_EVENT", `Conflicting duplicate Merkle leaf index ${result.index}`);
|
|
324
|
+
state.appends.set(result.index, { index: result.index, sequence: result.sequence, commitment: cloneBytes(commitment), event: cloneEvent(event) });
|
|
325
|
+
}
|
|
326
|
+
function recordSpentNullifier(state, nullifier) {
|
|
327
|
+
const value = hex(nullifier);
|
|
328
|
+
if (state.spentNullifiers.has(value))
|
|
329
|
+
reconstruction("DUPLICATE_EVENT", `Conflicting duplicate nullifier ${value}`);
|
|
330
|
+
state.spentNullifiers.add(value);
|
|
331
|
+
}
|
|
332
|
+
function applyEvent(state, pool, event) {
|
|
333
|
+
assertIdentity(pool, event);
|
|
334
|
+
if (event.generation !== state.tree.generation)
|
|
335
|
+
reconstruction("GENERATION_MISMATCH", `History event generation ${event.generation} does not match ${state.tree.generation}`);
|
|
336
|
+
if (event.kind === "unshield") {
|
|
337
|
+
assertAcceptedRoot(state.tree, event.rootSequence);
|
|
338
|
+
recordSpentNullifier(state, event.nullifier);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (event.kind === "shield") {
|
|
342
|
+
if (event.index !== state.tree.nextIndex || event.sequence !== state.tree.sequence + 1n)
|
|
343
|
+
reconstruction("SEQUENCE_GAP", `Shield event index or sequence is not contiguous at ${state.tree.nextIndex}`);
|
|
344
|
+
const result = appendLeaf(state.tree, event.commitment);
|
|
345
|
+
if (result.index !== event.index || result.sequence !== event.sequence || !same(result.root, event.root))
|
|
346
|
+
reconstruction("ROOT_MISMATCH", `Shield event root does not match replayed root at ${event.index}`);
|
|
347
|
+
appendRecord(state, result, event.commitment, event);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
assertAcceptedRoot(state.tree, event.rootSequence, event.root);
|
|
351
|
+
const expectedIndex = state.tree.nextIndex;
|
|
352
|
+
if (event.changeIndex !== undefined && event.changeIndex !== expectedIndex)
|
|
353
|
+
reconstruction("SEQUENCE_GAP", `Private-swap change index is not contiguous at ${expectedIndex}`);
|
|
354
|
+
if (event.outputIndex !== expectedIndex + (event.changeIndex === undefined ? 0n : 1n))
|
|
355
|
+
reconstruction("SEQUENCE_GAP", `Private-swap output index is not contiguous at ${expectedIndex}`);
|
|
356
|
+
if (event.changeIndex !== undefined) {
|
|
357
|
+
const change = appendLeaf(state.tree, event.changeCommitment);
|
|
358
|
+
appendRecord(state, change, event.changeCommitment, event);
|
|
359
|
+
}
|
|
360
|
+
const output = appendLeaf(state.tree, event.outputCommitment);
|
|
361
|
+
appendRecord(state, output, event.outputCommitment, event);
|
|
362
|
+
recordSpentNullifier(state, event.nullifier);
|
|
363
|
+
}
|
|
364
|
+
function serializeCheckpoint(state) {
|
|
365
|
+
const base = {
|
|
366
|
+
formatVersion: CHECKPOINT_VERSION,
|
|
367
|
+
identity: identityObject(state.identity),
|
|
368
|
+
tree: serializeTree(state.tree),
|
|
369
|
+
cursor: {
|
|
370
|
+
lastProcessedSignature: state.lastProcessedSignature,
|
|
371
|
+
lastFinalizedSlot: state.lastFinalizedSlot,
|
|
372
|
+
},
|
|
373
|
+
appends: [...state.appends.values()].sort((a, b) => Number(a.index - b.index)).map(record => ({
|
|
374
|
+
index: record.index.toString(),
|
|
375
|
+
sequence: record.sequence.toString(),
|
|
376
|
+
commitment: hex(record.commitment),
|
|
377
|
+
event: serializeEvent(record.event),
|
|
378
|
+
})),
|
|
379
|
+
spentNullifiers: [...state.spentNullifiers].sort(),
|
|
380
|
+
reconstructedRoot: hex(rootFromTree(state.tree)),
|
|
381
|
+
};
|
|
382
|
+
const integrity = createHash("sha256").update(JSON.stringify(base)).digest("hex");
|
|
383
|
+
return Uint8Array.from(Buffer.from(JSON.stringify({ ...base, integrity: { algorithm: "sha256", digest: integrity } }), "utf8"));
|
|
384
|
+
}
|
|
385
|
+
function deserializeCheckpoint(data, identity) {
|
|
386
|
+
let payload;
|
|
387
|
+
try {
|
|
388
|
+
payload = JSON.parse(Buffer.from(data).toString("utf8"));
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
reconstruction("INVALID_CHECKPOINT", "Merkle checkpoint is not valid JSON");
|
|
392
|
+
}
|
|
393
|
+
if (payload.formatVersion !== CHECKPOINT_VERSION || !payload.identity || identityKey(identity) !== JSON.stringify(payload.identity))
|
|
394
|
+
reconstruction("INVALID_CHECKPOINT", "Merkle checkpoint identity or version mismatch");
|
|
395
|
+
const integrity = payload.integrity;
|
|
396
|
+
const base = { ...payload };
|
|
397
|
+
delete base.integrity;
|
|
398
|
+
if (integrity?.algorithm !== "sha256" || typeof integrity.digest !== "string" || createHash("sha256").update(JSON.stringify(base)).digest("hex") !== integrity.digest)
|
|
399
|
+
reconstruction("INVALID_CHECKPOINT", "Merkle checkpoint integrity mismatch");
|
|
400
|
+
const tree = deserializeTree(payload.tree);
|
|
401
|
+
const state = {
|
|
402
|
+
identity: { ...identity },
|
|
403
|
+
tree,
|
|
404
|
+
appends: new Map(),
|
|
405
|
+
spentNullifiers: new Set(),
|
|
406
|
+
lastProcessedSignature: undefined,
|
|
407
|
+
lastFinalizedSlot: undefined,
|
|
408
|
+
};
|
|
409
|
+
const cursor = payload.cursor;
|
|
410
|
+
if (cursor?.lastProcessedSignature !== undefined && typeof cursor.lastProcessedSignature !== "string")
|
|
411
|
+
reconstruction("INVALID_CHECKPOINT", "Invalid checkpoint cursor");
|
|
412
|
+
if (cursor?.lastFinalizedSlot !== undefined && (!Number.isSafeInteger(cursor.lastFinalizedSlot) || Number(cursor.lastFinalizedSlot) < 0))
|
|
413
|
+
reconstruction("INVALID_CHECKPOINT", "Invalid checkpoint slot");
|
|
414
|
+
if ((cursor?.lastProcessedSignature === undefined) !== (cursor?.lastFinalizedSlot === undefined))
|
|
415
|
+
reconstruction("INVALID_CHECKPOINT", "Checkpoint cursor and slot must be recorded together");
|
|
416
|
+
state.lastProcessedSignature = cursor?.lastProcessedSignature;
|
|
417
|
+
state.lastFinalizedSlot = cursor?.lastFinalizedSlot === undefined ? undefined : Number(cursor.lastFinalizedSlot);
|
|
418
|
+
if (!Array.isArray(payload.appends) || !Array.isArray(payload.spentNullifiers) || typeof payload.reconstructedRoot !== "string")
|
|
419
|
+
reconstruction("INVALID_CHECKPOINT", "Invalid checkpoint contents");
|
|
420
|
+
for (const value of payload.spentNullifiers) {
|
|
421
|
+
const nullifier = parseBytes(value, 32, "spent nullifier");
|
|
422
|
+
if (!canonicalField(nullifier) || same(nullifier, new Uint8Array(32)))
|
|
423
|
+
reconstruction("INVALID_CHECKPOINT", "Invalid spent nullifier in Merkle checkpoint");
|
|
424
|
+
state.spentNullifiers.add(hex(nullifier));
|
|
425
|
+
}
|
|
426
|
+
for (const value of payload.appends) {
|
|
427
|
+
if (!value || typeof value !== "object")
|
|
428
|
+
reconstruction("INVALID_CHECKPOINT", "Invalid append record");
|
|
429
|
+
const record = value;
|
|
430
|
+
const index = parseBigInt(record.index, "append index");
|
|
431
|
+
const sequence = parseBigInt(record.sequence, "append sequence");
|
|
432
|
+
const commitment = parseBytes(record.commitment, 32, "append commitment");
|
|
433
|
+
const event = deserializeEvent(record.event);
|
|
434
|
+
if (state.appends.has(index))
|
|
435
|
+
reconstruction("INVALID_CHECKPOINT", "Duplicate append index in checkpoint");
|
|
436
|
+
state.appends.set(index, { index, sequence, commitment, event });
|
|
437
|
+
}
|
|
438
|
+
validateReplayState(state, identity.pool, parseBytes(payload.reconstructedRoot, 32, "reconstructed root"));
|
|
439
|
+
return state;
|
|
440
|
+
}
|
|
441
|
+
function validateReplayState(state, pool, expectedRoot) {
|
|
442
|
+
const poolKey = typeof pool === "string" ? pool : pool.toBase58();
|
|
443
|
+
validateTreeShape(state.tree, "checkpoint tree");
|
|
444
|
+
if (state.tree.pool.toBase58() !== poolKey || state.identity.pool !== poolKey || state.identity.tree.length === 0 || state.identity.generation !== state.tree.generation)
|
|
445
|
+
reconstruction("INVALID_CHECKPOINT", "Merkle checkpoint tree identity mismatch");
|
|
446
|
+
if (expectedRoot && !same(rootFromTree(state.tree), expectedRoot))
|
|
447
|
+
reconstruction("INVALID_CHECKPOINT", "Merkle checkpoint root mismatch");
|
|
448
|
+
if (state.appends.size !== Number(state.tree.nextIndex))
|
|
449
|
+
reconstruction("INVALID_CHECKPOINT", "Merkle checkpoint append count is inconsistent");
|
|
450
|
+
for (let index = 0n; index < state.tree.nextIndex; index++) {
|
|
451
|
+
const record = state.appends.get(index);
|
|
452
|
+
if (!record || record.index !== index || record.sequence !== index + 1n || record.commitment.length !== 32 || !canonicalField(record.commitment) || same(record.commitment, new Uint8Array(32)))
|
|
453
|
+
reconstruction("INVALID_CHECKPOINT", `Merkle checkpoint is missing leaf ${index}`);
|
|
454
|
+
if (!record.event.pool.equals(state.tree.pool) || record.event.generation !== state.tree.generation)
|
|
455
|
+
reconstruction("INVALID_CHECKPOINT", `Merkle checkpoint leaf ${index} has the wrong identity`);
|
|
456
|
+
if (record.event.kind === "shield") {
|
|
457
|
+
if (record.event.asset < 0 || record.event.asset > 1 || record.event.amount <= 0n || record.event.index !== record.index || record.event.sequence !== record.sequence || !same(record.event.commitment, record.commitment) || record.event.encryptedNote.length !== 186 || record.event.encryptedNote[0] !== 1 || !canonicalField(record.event.root) || same(record.event.root, new Uint8Array(32)))
|
|
458
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid shield append record ${index}`);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
const expected = record.index === record.event.changeIndex ? record.event.changeCommitment : record.index === record.event.outputIndex ? record.event.outputCommitment : undefined;
|
|
462
|
+
if (record.event.direction < 0 || record.event.direction > 1 || record.event.amountIn <= 0n || record.event.amountOut <= 0n || !expected || !same(expected, record.commitment) || !canonicalField(record.event.root) || !canonicalField(record.event.nullifier) || !canonicalField(record.event.outputCommitment) || same(record.event.outputCommitment, new Uint8Array(32)) || (record.event.changeIndex !== undefined && (!canonicalField(record.event.changeCommitment) || same(record.event.changeCommitment, new Uint8Array(32)))))
|
|
463
|
+
reconstruction("INVALID_CHECKPOINT", `Invalid private-swap append record ${index}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
validateCheckpointReplay(state);
|
|
467
|
+
}
|
|
468
|
+
function compareTreeState(local, current) {
|
|
469
|
+
validateTreeShape(current, "finalized tree");
|
|
470
|
+
if (!local.pool.equals(current.pool) || local.generation !== current.generation || local.nextIndex !== current.nextIndex || local.sequence !== current.sequence)
|
|
471
|
+
reconstruction("ROOT_MISMATCH", "Reconstructed tree counters disagree with finalized state");
|
|
472
|
+
const arrays = [
|
|
473
|
+
["frontier", local.frontier, current.frontier],
|
|
474
|
+
["emptySubtrees", local.emptySubtrees, current.emptySubtrees],
|
|
475
|
+
["roots", local.roots, current.roots],
|
|
476
|
+
];
|
|
477
|
+
for (const [name, left, right] of arrays)
|
|
478
|
+
if (left.length !== right.length || left.some((value, index) => !same(value, right[index])))
|
|
479
|
+
reconstruction("ROOT_MISMATCH", `${name} disagrees with finalized state`);
|
|
480
|
+
if (local.frontierPresent.some((value, index) => value !== current.frontierPresent[index]) || local.rootSequences.some((value, index) => value !== current.rootSequences[index]) || local.rootGenerations.some((value, index) => value !== current.rootGenerations[index]))
|
|
481
|
+
reconstruction("ROOT_MISMATCH", "Finalized Merkle metadata disagrees with reconstruction");
|
|
482
|
+
if (!same(rootFromTree(local), rootFromTree(current)))
|
|
483
|
+
reconstruction("ROOT_MISMATCH", "Reconstructed root disagrees with finalized root");
|
|
484
|
+
}
|
|
485
|
+
function validateCheckpointReplay(state) {
|
|
486
|
+
const replay = {
|
|
487
|
+
identity: { ...state.identity },
|
|
488
|
+
tree: cloneInitialTree(state.tree.pool, state.tree),
|
|
489
|
+
appends: new Map(),
|
|
490
|
+
spentNullifiers: new Set(),
|
|
491
|
+
};
|
|
492
|
+
const seenEvents = new Set();
|
|
493
|
+
const records = [...state.appends.values()].sort((left, right) => Number(left.index - right.index));
|
|
494
|
+
for (const record of records) {
|
|
495
|
+
const eventKey = JSON.stringify(serializeEvent(record.event));
|
|
496
|
+
if (seenEvents.has(eventKey)) {
|
|
497
|
+
if (record.event.kind !== "swap")
|
|
498
|
+
reconstruction("INVALID_CHECKPOINT", `Duplicate non-swap event in checkpoint at leaf ${record.index}`);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
seenEvents.add(eventKey);
|
|
502
|
+
applyEvent(replay, state.tree.pool, record.event);
|
|
503
|
+
}
|
|
504
|
+
compareTreeState(replay.tree, state.tree);
|
|
505
|
+
if (replay.appends.size !== state.appends.size)
|
|
506
|
+
reconstruction("INVALID_CHECKPOINT", "Checkpoint append records do not replay to the recorded tree");
|
|
507
|
+
for (const [index, record] of state.appends) {
|
|
508
|
+
const replayed = replay.appends.get(index);
|
|
509
|
+
if (!replayed || replayed.sequence !== record.sequence || !same(replayed.commitment, record.commitment) || JSON.stringify(serializeEvent(replayed.event)) !== JSON.stringify(serializeEvent(record.event)))
|
|
510
|
+
reconstruction("INVALID_CHECKPOINT", `Checkpoint append record ${index} does not match replay`);
|
|
511
|
+
}
|
|
512
|
+
for (const nullifier of replay.spentNullifiers)
|
|
513
|
+
if (!state.spentNullifiers.has(nullifier))
|
|
514
|
+
reconstruction("INVALID_CHECKPOINT", `Checkpoint is missing spent nullifier ${nullifier}`);
|
|
515
|
+
}
|
|
516
|
+
function messageHasProgramInstruction(transaction, programId, treeAddress) {
|
|
517
|
+
const message = transaction.transaction.message;
|
|
518
|
+
const loaded = transaction.meta?.loadedAddresses;
|
|
519
|
+
const keys = message.getAccountKeys(loaded ? { accountKeysFromLookups: loaded } : undefined);
|
|
520
|
+
return message.compiledInstructions.some(instruction => {
|
|
521
|
+
const instructionProgram = keys.get(instruction.programIdIndex);
|
|
522
|
+
return instructionProgram?.equals(programId) === true && instruction.accountKeyIndexes.some(index => keys.get(index)?.equals(treeAddress) === true);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
function programEventLogs(transaction, programId) {
|
|
526
|
+
const logs = transaction.meta?.logMessages ?? [];
|
|
527
|
+
const expected = programId.toBase58();
|
|
528
|
+
const stack = [];
|
|
529
|
+
const result = [];
|
|
530
|
+
for (const log of logs) {
|
|
531
|
+
const invoke = /^Program ([1-9A-HJ-NP-Za-km-z]+) invoke \[\d+\]$/.exec(log);
|
|
532
|
+
if (invoke) {
|
|
533
|
+
stack.push(invoke[1]);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const finished = /^Program ([1-9A-HJ-NP-z]+) (success|failed:.*)$/.exec(log);
|
|
537
|
+
if (finished) {
|
|
538
|
+
if (stack.length)
|
|
539
|
+
stack.pop();
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (log.startsWith("Program data: ") && stack.at(-1) === expected)
|
|
543
|
+
result.push(Buffer.from(log.slice("Program data: ".length), "base64"));
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
function parseEvent(data, signature, slot) {
|
|
548
|
+
if (data.subarray(0, 8).equals(SHIELD)) {
|
|
549
|
+
if (data.length < 85)
|
|
550
|
+
throw new Error("Malformed shield event");
|
|
551
|
+
let offset = 8;
|
|
552
|
+
const pool = pubkey(data, offset);
|
|
553
|
+
offset += 32;
|
|
554
|
+
const asset = data[offset++];
|
|
555
|
+
const amount = u64(data, offset);
|
|
556
|
+
offset += 8;
|
|
557
|
+
const commitment = data.subarray(offset, offset + 32);
|
|
558
|
+
offset += 32;
|
|
559
|
+
const encryptedLength = data.readUInt32LE(offset);
|
|
560
|
+
offset += 4;
|
|
561
|
+
if (asset > 1 || amount === 0n || encryptedLength !== 186 || data.length !== 8 + 32 + 1 + 8 + 32 + 4 + encryptedLength + 32 + 8 + 8)
|
|
562
|
+
throw new Error("Malformed shield event length or fields");
|
|
563
|
+
const encryptedNote = data.subarray(offset, offset + encryptedLength);
|
|
564
|
+
offset += encryptedLength;
|
|
565
|
+
const root = data.subarray(offset, offset + 32);
|
|
566
|
+
offset += 32;
|
|
567
|
+
if (encryptedNote[0] !== 1 || same(commitment, new Uint8Array(32)) || !canonicalField(commitment) || !canonicalField(root))
|
|
568
|
+
throw new Error("Malformed shield event payload");
|
|
569
|
+
const generation = u64(data, offset);
|
|
570
|
+
offset += 8;
|
|
571
|
+
const index = u64(data, offset);
|
|
572
|
+
return { kind: "shield", pool, asset, amount, commitment: Uint8Array.from(commitment), encryptedNote: Uint8Array.from(encryptedNote), root: Uint8Array.from(root), generation, index, sequence: index + 1n, slot, signature };
|
|
573
|
+
}
|
|
574
|
+
if (data.subarray(0, 8).equals(SWAP)) {
|
|
575
|
+
if (data.length !== 210 && data.length !== 218)
|
|
576
|
+
throw new Error("Malformed private-swap event length");
|
|
577
|
+
let offset = 8;
|
|
578
|
+
const pool = pubkey(data, offset);
|
|
579
|
+
offset += 32;
|
|
580
|
+
const direction = data[offset++];
|
|
581
|
+
const amountIn = u64(data, offset);
|
|
582
|
+
offset += 8;
|
|
583
|
+
const amountOut = u64(data, offset);
|
|
584
|
+
offset += 8;
|
|
585
|
+
const root = data.subarray(offset, offset + 32);
|
|
586
|
+
offset += 32;
|
|
587
|
+
const rootSequence = u64(data, offset);
|
|
588
|
+
offset += 8;
|
|
589
|
+
const generation = u64(data, offset);
|
|
590
|
+
offset += 8;
|
|
591
|
+
const nullifier = data.subarray(offset, offset + 32);
|
|
592
|
+
offset += 32;
|
|
593
|
+
const changeCommitment = data.subarray(offset, offset + 32);
|
|
594
|
+
offset += 32;
|
|
595
|
+
const outputCommitment = data.subarray(offset, offset + 32);
|
|
596
|
+
offset += 32;
|
|
597
|
+
const hasChangeValue = data[offset++];
|
|
598
|
+
if (hasChangeValue > 1)
|
|
599
|
+
throw new Error("Private-swap event has an invalid change flag");
|
|
600
|
+
const hasChange = hasChangeValue === 1;
|
|
601
|
+
if (direction > 1 || amountIn === 0n || amountOut === 0n || !canonicalField(root) || !canonicalField(nullifier) || same(nullifier, new Uint8Array(32)) || !canonicalField(outputCommitment) || (hasChange && !canonicalField(changeCommitment)) || same(outputCommitment, new Uint8Array(32)) || (hasChange && data.length !== 218) || (!hasChange && data.length !== 210))
|
|
602
|
+
throw new Error("Malformed private-swap event fields");
|
|
603
|
+
const changeIndex = hasChange ? u64(data, offset) : undefined;
|
|
604
|
+
if (hasChange)
|
|
605
|
+
offset += 8;
|
|
606
|
+
const outputIndex = u64(data, offset);
|
|
607
|
+
if (!hasChange && !same(changeCommitment, new Uint8Array(32)))
|
|
608
|
+
throw new Error("Private-swap event has a change commitment without an index");
|
|
609
|
+
if (hasChange && same(changeCommitment, new Uint8Array(32)))
|
|
610
|
+
throw new Error("Private-swap event has an empty change commitment");
|
|
611
|
+
return { kind: "swap", pool, direction, amountIn, amountOut, root: Uint8Array.from(root), rootSequence, generation, nullifier: Uint8Array.from(nullifier), changeCommitment: Uint8Array.from(changeCommitment), outputCommitment: Uint8Array.from(outputCommitment), changeIndex, outputIndex, slot, signature };
|
|
612
|
+
}
|
|
613
|
+
if (data.subarray(0, 8).equals(UNSHIELD)) {
|
|
614
|
+
if (data.length !== 129)
|
|
615
|
+
throw new Error("Malformed unshield event length");
|
|
616
|
+
let offset = 8;
|
|
617
|
+
const pool = pubkey(data, offset);
|
|
618
|
+
offset += 32;
|
|
619
|
+
const asset = data[offset++];
|
|
620
|
+
const amount = u64(data, offset);
|
|
621
|
+
offset += 8;
|
|
622
|
+
const recipient = pubkey(data, offset);
|
|
623
|
+
offset += 32;
|
|
624
|
+
const nullifier = data.subarray(offset, offset + 32);
|
|
625
|
+
offset += 32;
|
|
626
|
+
const generation = u64(data, offset);
|
|
627
|
+
offset += 8;
|
|
628
|
+
const rootSequence = u64(data, offset);
|
|
629
|
+
if (asset > 1 || amount === 0n || !canonicalField(nullifier) || same(nullifier, new Uint8Array(32)))
|
|
630
|
+
throw new Error("Malformed unshield event fields");
|
|
631
|
+
return { kind: "unshield", pool, asset, amount, recipient, nullifier: Uint8Array.from(nullifier), generation, rootSequence, slot, signature };
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
function isRateLimit(error) {
|
|
636
|
+
return /429|too many requests|rate limit/i.test(String(error));
|
|
637
|
+
}
|
|
638
|
+
async function retryRpc(label, operation, retryNull = false) {
|
|
639
|
+
let lastError;
|
|
640
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
641
|
+
try {
|
|
642
|
+
const result = await operation();
|
|
643
|
+
if (!(retryNull && result === null))
|
|
644
|
+
return result;
|
|
645
|
+
lastError = new Error(`${label} returned no result`);
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
if (!isRateLimit(error))
|
|
649
|
+
throw new MerkleReconstructionError("HISTORY_RPC", `${label} failed: ${String(error)}`);
|
|
650
|
+
lastError = error;
|
|
651
|
+
}
|
|
652
|
+
if (attempt < 4)
|
|
653
|
+
await sleep(Math.min(4_000, 250 * 2 ** attempt) + Math.floor(Math.random() * 100));
|
|
654
|
+
}
|
|
655
|
+
throw new MerkleReconstructionError("HISTORY_RPC", `${label} retry limit exceeded: ${String(lastError)}`);
|
|
656
|
+
}
|
|
657
|
+
async function retryTransaction(connection, signature) {
|
|
658
|
+
try {
|
|
659
|
+
const transaction = await retryRpc(`getTransaction ${signature}`, () => connection.getTransaction(signature, { commitment: "finalized", maxSupportedTransactionVersion: 0 }), true);
|
|
660
|
+
if (!transaction)
|
|
661
|
+
reconstruction("TRANSACTION_MISSING", `Finalized transaction is missing: ${signature}`);
|
|
662
|
+
return transaction;
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
if (error instanceof MerkleReconstructionError && error.code === "HISTORY_RPC" && error.message.includes("returned no result"))
|
|
666
|
+
reconstruction("TRANSACTION_MISSING", `Finalized transaction is missing: ${signature}`);
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
export class RpcMerkleWitnessProvider {
|
|
671
|
+
connection;
|
|
672
|
+
programId;
|
|
673
|
+
getTree;
|
|
674
|
+
checkpointStore;
|
|
675
|
+
states = new Map();
|
|
676
|
+
syncing = new Map();
|
|
677
|
+
genesisHash;
|
|
678
|
+
constructor(connection, programId, getTree, checkpointStore) {
|
|
679
|
+
this.connection = connection;
|
|
680
|
+
this.programId = programId;
|
|
681
|
+
this.getTree = getTree;
|
|
682
|
+
this.checkpointStore = checkpointStore;
|
|
683
|
+
}
|
|
684
|
+
async getGenesisIdentity() {
|
|
685
|
+
if (this.genesisHash)
|
|
686
|
+
return this.genesisHash;
|
|
687
|
+
const genesisHash = await retryRpc("getGenesisHash", () => this.connection.getGenesisHash());
|
|
688
|
+
if (typeof genesisHash !== "string" || genesisHash.length === 0)
|
|
689
|
+
reconstruction("HISTORY_RPC", "RPC returned an invalid genesis hash");
|
|
690
|
+
return this.genesisHash = genesisHash;
|
|
691
|
+
}
|
|
692
|
+
async identity(pool, tree) {
|
|
693
|
+
const [treeAddress] = PublicKey.findProgramAddressSync([Buffer.from("tree"), pool.toBuffer()], this.programId);
|
|
694
|
+
return { genesisHash: await this.getGenesisIdentity(), programId: this.programId.toBase58(), pool: pool.toBase58(), tree: treeAddress.toBase58(), generation: tree.generation };
|
|
695
|
+
}
|
|
696
|
+
async loadCheckpoint(identity, pool) {
|
|
697
|
+
if (!this.checkpointStore)
|
|
698
|
+
return undefined;
|
|
699
|
+
let data;
|
|
700
|
+
try {
|
|
701
|
+
data = await this.checkpointStore.loadMerkleCheckpoint(identityKey(identity));
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (error instanceof MerkleReconstructionError)
|
|
705
|
+
throw error;
|
|
706
|
+
reconstruction("INVALID_CHECKPOINT", `Unable to authenticate Merkle checkpoint: ${String(error)}`);
|
|
707
|
+
}
|
|
708
|
+
if (!data)
|
|
709
|
+
return undefined;
|
|
710
|
+
const state = deserializeCheckpoint(data, identity);
|
|
711
|
+
validateReplayState(state, pool);
|
|
712
|
+
return state;
|
|
713
|
+
}
|
|
714
|
+
async collectRows(treeAddress, cursor) {
|
|
715
|
+
let before;
|
|
716
|
+
let foundCursor = cursor === undefined;
|
|
717
|
+
const rows = [];
|
|
718
|
+
const seenSignatures = new Set();
|
|
719
|
+
let previousSlot;
|
|
720
|
+
for (;;) {
|
|
721
|
+
const page = await retryRpc("getSignaturesForAddress", () => this.connection.getSignaturesForAddress(treeAddress, { before, limit: 1000 }, "finalized"));
|
|
722
|
+
if (!page.length)
|
|
723
|
+
break;
|
|
724
|
+
if (page.length > 1000)
|
|
725
|
+
reconstruction("HISTORY_GAP", "RPC returned an oversized finalized history page");
|
|
726
|
+
for (const row of page) {
|
|
727
|
+
if (typeof row.signature !== "string" || row.signature.length === 0 || !Number.isSafeInteger(row.slot) || row.slot < 0)
|
|
728
|
+
reconstruction("HISTORY_GAP", "RPC returned malformed finalized signature history");
|
|
729
|
+
if (seenSignatures.has(row.signature))
|
|
730
|
+
reconstruction("DUPLICATE_EVENT", `Duplicate finalized transaction signature: ${row.signature}`);
|
|
731
|
+
seenSignatures.add(row.signature);
|
|
732
|
+
if (previousSlot !== undefined && row.slot > previousSlot)
|
|
733
|
+
reconstruction("HISTORY_GAP", "Finalized signature history is not in canonical slot order");
|
|
734
|
+
previousSlot = row.slot;
|
|
735
|
+
if (row.confirmationStatus !== "finalized")
|
|
736
|
+
reconstruction("HISTORY_GAP", `Finalized signature has unexpected confirmation status: ${row.signature}`);
|
|
737
|
+
if (cursor !== undefined && row.signature === cursor) {
|
|
738
|
+
foundCursor = true;
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
rows.push({ signature: row.signature, slot: row.slot, err: row.err, confirmationStatus: row.confirmationStatus });
|
|
742
|
+
}
|
|
743
|
+
if ((cursor !== undefined && foundCursor) || page.length < 1000)
|
|
744
|
+
break;
|
|
745
|
+
const nextBefore = page[page.length - 1].signature;
|
|
746
|
+
if (nextBefore === before)
|
|
747
|
+
reconstruction("HISTORY_GAP", "Finalized signature pagination did not advance");
|
|
748
|
+
before = nextBefore;
|
|
749
|
+
}
|
|
750
|
+
if (cursor !== undefined && !foundCursor)
|
|
751
|
+
reconstruction("HISTORY_GAP", `Checkpoint cursor ${cursor} is not present in finalized history`);
|
|
752
|
+
return rows.reverse();
|
|
753
|
+
}
|
|
754
|
+
async transaction(row) {
|
|
755
|
+
const transaction = await retryTransaction(this.connection, row.signature);
|
|
756
|
+
if (!Number.isSafeInteger(transaction.slot) || transaction.slot < 0)
|
|
757
|
+
reconstruction("HISTORY_GAP", `Transaction has an invalid slot: ${row.signature}`);
|
|
758
|
+
if (transaction.slot !== row.slot)
|
|
759
|
+
reconstruction("HISTORY_GAP", `Transaction slot disagrees with signature history: ${row.signature}`);
|
|
760
|
+
if (!transaction.meta || transaction.meta.err !== null || row.err !== null && row.err !== undefined)
|
|
761
|
+
reconstruction("FAILED_TRANSACTION", `Finalized history transaction failed: ${row.signature}`);
|
|
762
|
+
if (!Array.isArray(transaction.transaction.signatures) || transaction.transaction.signatures[0] !== row.signature)
|
|
763
|
+
reconstruction("HISTORY_GAP", `Transaction signature does not match finalized history: ${row.signature}`);
|
|
764
|
+
return transaction;
|
|
765
|
+
}
|
|
766
|
+
async syncPool(pool) {
|
|
767
|
+
const poolKey = pool.toBase58();
|
|
768
|
+
const current = await this.getTree(pool);
|
|
769
|
+
validateTreeShape(current, "finalized tree");
|
|
770
|
+
if (!current.pool.equals(pool))
|
|
771
|
+
reconstruction("INVALID_TREE", "Finalized tree belongs to another pool");
|
|
772
|
+
const identity = await this.identity(pool, current);
|
|
773
|
+
const stateKey = identityKey(identity);
|
|
774
|
+
let state = this.states.get(stateKey);
|
|
775
|
+
if (!state)
|
|
776
|
+
state = await this.loadCheckpoint(identity, pool);
|
|
777
|
+
if (state) {
|
|
778
|
+
if (state.identity.generation !== current.generation)
|
|
779
|
+
reconstruction("GENERATION_MISMATCH", "Checkpoint generation differs from finalized tree");
|
|
780
|
+
if (!state.tree.emptySubtrees.every((value, index) => same(value, current.emptySubtrees[index])))
|
|
781
|
+
reconstruction("ROOT_MISMATCH", "Checkpoint empty subtrees differ from finalized tree");
|
|
782
|
+
if (state.tree.nextIndex > current.nextIndex)
|
|
783
|
+
reconstruction("SEQUENCE_GAP", "Checkpoint is ahead of finalized tree");
|
|
784
|
+
if (state.tree.nextIndex === current.nextIndex) {
|
|
785
|
+
const stateRoot = rootFromTree(state.tree);
|
|
786
|
+
if (!same(stateRoot, rootFromTree(current)))
|
|
787
|
+
reconstruction("ROOT_MISMATCH", "Checkpoint root differs from finalized tree");
|
|
788
|
+
}
|
|
789
|
+
state = cloneState(state);
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
state = { identity, tree: cloneInitialTree(pool, current), appends: new Map(), spentNullifiers: new Set() };
|
|
793
|
+
}
|
|
794
|
+
const [treeAddress] = PublicKey.findProgramAddressSync([Buffer.from("tree"), pool.toBuffer()], this.programId);
|
|
795
|
+
const rows = await this.collectRows(treeAddress, state.lastProcessedSignature);
|
|
796
|
+
for (const row of rows) {
|
|
797
|
+
state.lastProcessedSignature = row.signature;
|
|
798
|
+
state.lastFinalizedSlot = row.slot;
|
|
799
|
+
const transaction = await this.transaction(row);
|
|
800
|
+
if (!messageHasProgramInstruction(transaction, this.programId, treeAddress))
|
|
801
|
+
continue;
|
|
802
|
+
for (const data of programEventLogs(transaction, this.programId)) {
|
|
803
|
+
let event;
|
|
804
|
+
try {
|
|
805
|
+
event = parseEvent(data, row.signature, row.slot);
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
reconstruction("HISTORY_GAP", `Malformed finalized event in ${row.signature}: ${String(error)}`);
|
|
809
|
+
}
|
|
810
|
+
if (event)
|
|
811
|
+
applyEvent(state, pool, event);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
compareTreeState(state.tree, current);
|
|
815
|
+
if (this.checkpointStore && (rows.length > 0 || !this.states.has(stateKey)))
|
|
816
|
+
await this.checkpointStore.saveMerkleCheckpoint(stateKey, serializeCheckpoint(state));
|
|
817
|
+
this.states.set(stateKey, state);
|
|
818
|
+
return state;
|
|
819
|
+
}
|
|
820
|
+
async load(pool) {
|
|
821
|
+
const poolKey = pool.toBase58();
|
|
822
|
+
const running = this.syncing.get(poolKey);
|
|
823
|
+
if (running)
|
|
824
|
+
return running;
|
|
825
|
+
const task = this.syncPool(pool);
|
|
826
|
+
this.syncing.set(poolKey, task);
|
|
827
|
+
try {
|
|
828
|
+
return await task;
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
this.syncing.delete(poolKey);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
async getShieldEvents(pool) {
|
|
835
|
+
const state = await this.load(pool);
|
|
836
|
+
return [...state.appends.values()].filter(record => record.event.kind === "shield").sort((a, b) => Number(a.index - b.index)).map(record => cloneEvent(record.event));
|
|
837
|
+
}
|
|
838
|
+
async getSpentNullifiers(pool) {
|
|
839
|
+
const state = await this.load(pool);
|
|
840
|
+
return [...state.spentNullifiers].map(bytes);
|
|
841
|
+
}
|
|
842
|
+
async getWitness(pool, commitment) {
|
|
843
|
+
const state = await this.load(pool);
|
|
844
|
+
const current = await this.getTree(pool);
|
|
845
|
+
compareTreeState(state.tree, current);
|
|
846
|
+
const leaf = [...state.appends.values()].find(record => same(record.commitment, commitment));
|
|
847
|
+
if (!leaf)
|
|
848
|
+
throw new Error("Note commitment is not present in the reconstructed tree");
|
|
849
|
+
let levelNodes = new Map([...state.appends.values()].map(record => [record.index, record.commitment]));
|
|
850
|
+
const siblings = [];
|
|
851
|
+
let nodeIndex = leaf.index;
|
|
852
|
+
for (let level = 0; level < TREE_DEPTH; level++) {
|
|
853
|
+
const siblingIndex = nodeIndex ^ 1n;
|
|
854
|
+
siblings.push(cloneBytes(levelNodes.get(siblingIndex) ?? current.emptySubtrees[level]));
|
|
855
|
+
const next = new Map();
|
|
856
|
+
const parents = new Set([...levelNodes.keys()].map(index => index >> 1n));
|
|
857
|
+
parents.add(nodeIndex >> 1n);
|
|
858
|
+
for (const parent of parents) {
|
|
859
|
+
const left = levelNodes.get(parent * 2n) ?? current.emptySubtrees[level];
|
|
860
|
+
const right = levelNodes.get(parent * 2n + 1n) ?? current.emptySubtrees[level];
|
|
861
|
+
next.set(parent, hash2(left, right));
|
|
862
|
+
}
|
|
863
|
+
levelNodes = next;
|
|
864
|
+
nodeIndex >>= 1n;
|
|
865
|
+
}
|
|
866
|
+
const sequence = current.sequence;
|
|
867
|
+
const root = current.roots[Number(sequence % BigInt(ROOT_HISTORY))];
|
|
868
|
+
if (!root || !verifyPath(commitment, leaf.index, siblings, root))
|
|
869
|
+
reconstruction("ROOT_MISMATCH", "Reconstructed Merkle witness does not match finalized root");
|
|
870
|
+
return { index: leaf.index, siblings, root: cloneBytes(root), rootSequence: sequence, generation: current.generation };
|
|
871
|
+
}
|
|
872
|
+
}
|