@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/dist/wallet.js ADDED
@@ -0,0 +1,491 @@
1
+ import { ComputeBudgetProgram } from "@solana/web3.js";
2
+ import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from "@solana/spl-token";
3
+ import { cryptoRandom, decryptNotePayload, encryptNote, encodeNote, keyHierarchy, noteCommitment, nullifier, ownerCommitment } from "./crypto.js";
4
+ import { accountDiscriminator } from "./encoding.js";
5
+ import { privateSwap as privateSwapIx, shield as shieldIx, unshield as unshieldIx } from "./instructions.js";
6
+ import { encodePrivateSwapPublicInputs, encodeUnshieldPublicInputs } from "./prover.js";
7
+ import { swapOutputPreservingLpClaims } from "./math.js";
8
+ import { pda } from "./pda.js";
9
+ import { transactionSignatureFromBytes } from "./client.js";
10
+ const key = (value) => Buffer.from(value).toString("hex");
11
+ const clone = (note) => ({ ...note, ownerCommitment: Uint8Array.from(note.ownerCommitment), randomness: Uint8Array.from(note.randomness), commitment: Uint8Array.from(note.commitment), encryptedPayload: note.encryptedPayload && Uint8Array.from(note.encryptedPayload), operationId: note.operationId && Uint8Array.from(note.operationId) });
12
+ const state = (note) => note.state ?? (note.spent ? "spent" : "available");
13
+ const same = (a, b) => Buffer.from(a).equals(Buffer.from(b));
14
+ const noteFingerprint = (note) => JSON.stringify({ pool: note.pool.toBase58(), asset: note.asset.toBase58(), amount: note.amount.toString(), ownerCommitment: key(note.ownerCommitment), randomness: key(note.randomness), commitment: key(note.commitment), encryptedPayload: note.encryptedPayload && key(note.encryptedPayload), leafIndex: note.leafIndex?.toString(), state: state(note), operationId: note.operationId && key(note.operationId), transactionSignature: note.transactionSignature });
15
+ export class InMemoryNoteStore {
16
+ notes = new Map();
17
+ operations = new Map();
18
+ async getNotes(ownerCommitment) {
19
+ return [...this.notes.values()].filter(note => !ownerCommitment || same(note.ownerCommitment, ownerCommitment)).map(clone);
20
+ }
21
+ async saveNote(input) {
22
+ const note = clone({ ...input, state: input.state ?? "available", spent: input.state === "spent" || input.spent === true });
23
+ const existing = this.notes.get(key(note.commitment));
24
+ if (existing && noteFingerprint(existing) !== noteFingerprint(note))
25
+ throw new Error("Conflicting duplicate note commitment");
26
+ if (!existing)
27
+ this.notes.set(key(note.commitment), note);
28
+ }
29
+ async reserveNote(commitment, operationId, owner) {
30
+ const note = this.notes.get(key(commitment));
31
+ if (!note || !same(note.ownerCommitment, owner) || state(note) !== "available")
32
+ throw new Error("Note is not available");
33
+ note.state = "reserved";
34
+ note.spent = false;
35
+ note.operationId = Uint8Array.from(operationId);
36
+ return clone(note);
37
+ }
38
+ async reserveNoteAndBegin(commitment, operationId, owner, operation) {
39
+ const note = this.notes.get(key(commitment));
40
+ if (!note || !same(note.ownerCommitment, owner) || state(note) !== "available")
41
+ throw new Error("Note is not available");
42
+ if (!same(operation.id, operationId) || !operation.inputCommitment || !same(operation.inputCommitment, commitment))
43
+ throw new Error("Invalid reserve-and-begin operation");
44
+ if (this.operations.has(key(operationId)))
45
+ throw new Error("Duplicate operation ID");
46
+ note.state = "reserved";
47
+ note.spent = false;
48
+ note.operationId = Uint8Array.from(operationId);
49
+ this.operations.set(key(operationId), { ...operation, id: Uint8Array.from(operation.id), outputNotes: operation.outputNotes.map(clone), metadata: { ...operation.metadata } });
50
+ return clone(note);
51
+ }
52
+ async markSubmitted(commitment, operationId, signature) {
53
+ const note = this.notes.get(key(commitment));
54
+ if (note && state(note) === "submitted" && note.transactionSignature === signature)
55
+ return;
56
+ if (!note || state(note) !== "reserved" || !note.operationId || !same(note.operationId, operationId))
57
+ throw new Error("Note reservation mismatch");
58
+ note.state = "submitted";
59
+ note.transactionSignature = signature;
60
+ }
61
+ async markSpent(commitment, operationId) {
62
+ const note = this.notes.get(key(commitment));
63
+ if (note && state(note) === "spent")
64
+ return;
65
+ if (!note || (state(note) !== "reserved" && state(note) !== "submitted"))
66
+ throw new Error("Note is not pending spend");
67
+ if (operationId && (!note.operationId || !same(note.operationId, operationId)))
68
+ throw new Error("Note reservation mismatch");
69
+ note.state = "spent";
70
+ note.spent = true;
71
+ note.operationId = undefined;
72
+ }
73
+ async releaseReservation(commitment, operationId) {
74
+ const note = this.notes.get(key(commitment));
75
+ if (note && state(note) === "available")
76
+ return;
77
+ if (!note || (state(note) !== "reserved" && state(note) !== "submitted"))
78
+ throw new Error("Note is not reserved");
79
+ if (operationId && (!note.operationId || !same(note.operationId, operationId)))
80
+ throw new Error("Note reservation mismatch");
81
+ note.state = "available";
82
+ note.spent = false;
83
+ note.operationId = undefined;
84
+ note.transactionSignature = undefined;
85
+ }
86
+ async beginOperation(operation) { if (this.operations.has(key(operation.id)))
87
+ throw new Error("Duplicate operation ID"); this.operations.set(key(operation.id), { ...operation, id: Uint8Array.from(operation.id), outputNotes: operation.outputNotes.map(clone), metadata: { ...operation.metadata } }); }
88
+ async updateOperation(id, patch) { const operation = this.operations.get(key(id)); if (!operation)
89
+ throw new Error("Unknown operation"); operation.metadata = { ...operation.metadata, ...(patch.metadata ?? {}) }; if (patch.outputNotes)
90
+ operation.outputNotes = patch.outputNotes.map(clone); }
91
+ async markPrepared(id, signedTransaction, lastValidBlockHeight) { const operation = this.operations.get(key(id)); if (!operation)
92
+ throw new Error("Unknown operation"); operation.state = "prepared"; operation.signedTransaction = Uint8Array.from(signedTransaction); operation.lastValidBlockHeight = lastValidBlockHeight; }
93
+ async markOperationSubmitted(id, signature) { const operation = this.operations.get(key(id)); if (!operation)
94
+ throw new Error("Unknown operation"); operation.state = "submitted"; operation.signature = signature; }
95
+ async markOperationFinalized(id) { const operation = this.operations.get(key(id)); if (!operation)
96
+ throw new Error("Unknown operation"); if (operation.state === "finalized")
97
+ return; operation.state = "finalized"; }
98
+ async markOperationFailed(id, reason) { const operation = this.operations.get(key(id)); if (!operation)
99
+ throw new Error("Unknown operation"); if (operation.state === "failed")
100
+ return; operation.state = "failed"; operation.metadata.failure = reason; }
101
+ async markOperationUnknown(id, reason) { const operation = this.operations.get(key(id)); if (!operation)
102
+ throw new Error("Unknown operation"); if (operation.state === "unknown")
103
+ return; operation.state = "unknown"; operation.metadata.unknown = reason; }
104
+ async getPendingOperations() { return [...this.operations.values()].filter(operation => operation.state !== "finalized" && operation.state !== "failed").map(operation => ({ ...operation, id: Uint8Array.from(operation.id), outputNotes: operation.outputNotes.map(clone), metadata: { ...operation.metadata } })); }
105
+ }
106
+ export class ShieldedWallet {
107
+ sdk;
108
+ prover;
109
+ witnessProvider;
110
+ spendSecret;
111
+ viewKey;
112
+ ownerCommitment;
113
+ store;
114
+ constructor(sdk, seed, prover, witnessProvider, store) {
115
+ this.sdk = sdk;
116
+ this.prover = prover;
117
+ this.witnessProvider = witnessProvider;
118
+ if (!store)
119
+ throw new Error("An explicit NoteStore is required; volatile storage is not a production default");
120
+ const keys = keyHierarchy(seed);
121
+ this.spendSecret = keys.spendSecret;
122
+ this.viewKey = keys.viewKey;
123
+ this.ownerCommitment = ownerCommitment(this.spendSecret);
124
+ this.store = store;
125
+ }
126
+ journal() {
127
+ const store = this.store;
128
+ return typeof store.beginOperation === "function" && typeof store.reserveNoteAndBegin === "function" ? store : undefined;
129
+ }
130
+ async addNote(note) {
131
+ await this.store.saveNote(note);
132
+ }
133
+ async getNotes() {
134
+ return this.store.getNotes(this.ownerCommitment);
135
+ }
136
+ async getPrivateBalance(input) {
137
+ return (await this.getNotes()).filter(note => note.pool.equals(input.pool) && note.asset.equals(input.mint) && state(note) === "available").reduce((amount, note) => amount + note.amount, 0n);
138
+ }
139
+ requireBackend() {
140
+ if (!this.prover)
141
+ throw new Error("A production prover is required");
142
+ if (!this.witnessProvider)
143
+ throw new Error("A Merkle witness provider is required");
144
+ return { prover: this.prover, witness: this.witnessProvider };
145
+ }
146
+ async select(pool, mint, amount) {
147
+ const candidates = (await this.getNotes()).filter(note => note.pool.equals(pool) && note.asset.equals(mint) && state(note) === "available" && note.amount >= amount);
148
+ if (!candidates.length)
149
+ throw new Error("Insufficient private balance");
150
+ return candidates[0];
151
+ }
152
+ async begin(operation) {
153
+ const journal = this.journal();
154
+ if (journal)
155
+ await journal.beginOperation(operation);
156
+ }
157
+ async reserveAndBegin(note, operation) {
158
+ const journal = this.journal();
159
+ if (journal)
160
+ return journal.reserveNoteAndBegin(note.commitment, operation.id, this.ownerCommitment, operation);
161
+ await this.store.reserveNote(note.commitment, operation.id, this.ownerCommitment);
162
+ return note;
163
+ }
164
+ async fail(operationId, note, reason, journaled = true) {
165
+ try {
166
+ if (note)
167
+ await this.store.releaseReservation(note.commitment, operationId);
168
+ }
169
+ finally {
170
+ const journal = this.journal();
171
+ if (journal && journaled)
172
+ await journal.markOperationFailed(operationId, reason ?? "operation failed");
173
+ }
174
+ }
175
+ async unknown(operationId, reason, signature) {
176
+ const journal = this.journal();
177
+ if (!journal)
178
+ return;
179
+ try {
180
+ if (signature)
181
+ await journal.updateOperation(operationId, { metadata: { signature } });
182
+ await journal.markOperationUnknown(operationId, reason);
183
+ }
184
+ catch {
185
+ // Keep reserved notes untouched when local journaling is unavailable.
186
+ }
187
+ }
188
+ async submitted(operationId, signature, note) {
189
+ if (note)
190
+ await this.store.markSubmitted(note.commitment, operationId, signature);
191
+ const journal = this.journal();
192
+ if (journal)
193
+ await journal.markOperationSubmitted(operationId, signature);
194
+ }
195
+ async finalized(operationId, note, outputNotes = []) {
196
+ if (note)
197
+ await this.store.markSpent(note.commitment, operationId);
198
+ for (const output of outputNotes)
199
+ await this.store.saveNote(output);
200
+ const journal = this.journal();
201
+ if (journal)
202
+ await journal.markOperationFinalized(operationId);
203
+ }
204
+ async shield(input) {
205
+ const pool = await this.sdk.getPool(input.pool);
206
+ const stateAccount = await this.sdk.getShieldedState(input.pool);
207
+ const asset = input.mint.equals(pool.tokenAMint) ? 0 : input.mint.equals(pool.tokenBMint) ? 1 : -1;
208
+ if (asset < 0)
209
+ throw new Error("Mint is not a pool asset");
210
+ const randomness = cryptoRandom(32);
211
+ const commitment = noteCommitment(input.pool, input.mint, input.amount, this.ownerCommitment, randomness);
212
+ const encrypted = encryptNote(encodeNote(input.pool, input.mint, input.amount, this.ownerCommitment, randomness), this.viewKey, input.pool, input.mint, commitment);
213
+ const note = { pool: input.pool, asset: input.mint, amount: input.amount, ownerCommitment: this.ownerCommitment, randomness, commitment, encryptedPayload: encrypted, state: "available" };
214
+ const operationId = cryptoRandom(32);
215
+ await this.begin({ id: operationId, kind: "shield", state: "intent", pool: input.pool, metadata: { asset: input.mint.toBase58(), amount: input.amount.toString(), commitment: key(commitment) }, outputNotes: [note] });
216
+ try {
217
+ const [depositorA, depositorB] = await this.sdk.ensureAtas([{ mint: pool.tokenAMint, owner: this.sdk.wallet.publicKey }, { mint: pool.tokenBMint, owner: this.sdk.wallet.publicKey }]);
218
+ const outcome = await this.sdk.buildAndSendOutcome([
219
+ shieldIx(this.sdk.wallet.publicKey, pool, stateAccount, asset, input.amount, this.ownerCommitment, randomness, encrypted, depositorA, depositorB, this.sdk.programId),
220
+ ], {
221
+ onPrepared: async (transaction, lastValidBlockHeight) => { const journal = this.journal(); if (journal)
222
+ await journal.markPrepared(operationId, transaction, lastValidBlockHeight); },
223
+ onSubmitted: async (signature) => this.submitted(operationId, signature),
224
+ });
225
+ if (outcome.status === "finalized-success") {
226
+ await this.finalized(operationId, undefined, [note]);
227
+ return { signature: outcome.signature, note };
228
+ }
229
+ if (outcome.status === "finalized-failed") {
230
+ await this.fail(operationId, undefined, String(outcome.error));
231
+ throw new Error(`Shield transaction failed: ${String(outcome.error)}`);
232
+ }
233
+ await this.unknown(operationId, outcome.reason, outcome.signature);
234
+ throw new Error(`Shield transaction outcome is unknown: ${outcome.reason}`);
235
+ }
236
+ catch (error) {
237
+ if (!String(error).includes("outcome is unknown") && !String(error).includes("TransactionUnknownError"))
238
+ await this.fail(operationId, undefined, String(error));
239
+ throw error;
240
+ }
241
+ }
242
+ async unshield(input) {
243
+ const { prover, witness } = this.requireBackend();
244
+ this.sdk.assertPrivateTransactionReady();
245
+ const operationId = cryptoRandom(32);
246
+ let note = await this.select(input.pool, input.mint, input.amount);
247
+ let journaled = false;
248
+ let reserved = false;
249
+ try {
250
+ if (note.amount !== input.amount)
251
+ throw new Error("Unshield consumes a complete note; partial unshield is not supported");
252
+ note = await this.reserveAndBegin(note, { id: operationId, kind: "unshield", state: "intent", pool: input.pool, inputCommitment: note.commitment, metadata: { asset: input.mint.toBase58(), amount: input.amount.toString(), recipient: input.recipient.toBase58() }, outputNotes: [] });
253
+ journaled = true;
254
+ reserved = true;
255
+ const pool = await this.sdk.getPool(input.pool);
256
+ const stateAccount = await this.sdk.getShieldedState(input.pool);
257
+ const witnessValue = await witness.getWitness(input.pool, note.commitment);
258
+ const nullifierValue = nullifier(input.pool, input.mint, this.spendSecret, note.randomness);
259
+ const preflightRecipientA = getAssociatedTokenAddressSync(pool.tokenAMint, input.recipient, true, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
260
+ const preflightRecipientB = getAssociatedTokenAddressSync(pool.tokenBMint, input.recipient, true, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
261
+ await this.sdk.validatePrivateTransactionReady([
262
+ ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }),
263
+ unshieldIx(this.sdk.wallet.publicKey, pool, stateAccount, input.mint.equals(pool.tokenAMint) ? 0 : 1, note.amount, witnessValue.root, witnessValue.rootSequence, witnessValue.generation, nullifierValue, input.recipient, preflightRecipientA, preflightRecipientB, new Uint8Array(256), new Uint8Array(320), this.sdk.programId),
264
+ ]);
265
+ const journal = this.journal();
266
+ if (journal)
267
+ await journal.updateOperation(operationId, { metadata: { nullifier: key(nullifierValue), amount: note.amount.toString() } });
268
+ const result = await prover.proveUnshield({ pool: input.pool, asset: input.mint, root: witnessValue.root, rootSequence: witnessValue.rootSequence, generation: witnessValue.generation, nullifier: nullifierValue, amount: note.amount, recipient: input.recipient, spendSecret: this.spendSecret, randomness: note.randomness, witness: witnessValue });
269
+ const expected = encodeUnshieldPublicInputs({ pool: input.pool, asset: input.mint, root: witnessValue.root, nullifier: nullifierValue, amount: note.amount, recipient: input.recipient });
270
+ if (!Buffer.from(result.publicInputs).equals(Buffer.from(expected)))
271
+ throw new Error("Prover returned mismatched unshield public inputs");
272
+ const [recipientA, recipientB] = await this.sdk.ensureAtas([{ mint: pool.tokenAMint, owner: input.recipient, allowOwnerOffCurve: true }, { mint: pool.tokenBMint, owner: input.recipient, allowOwnerOffCurve: true }]);
273
+ const outcome = await this.sdk.buildAndSendOutcome([
274
+ ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }),
275
+ unshieldIx(this.sdk.wallet.publicKey, pool, stateAccount, input.mint.equals(pool.tokenAMint) ? 0 : 1, note.amount, witnessValue.root, witnessValue.rootSequence, witnessValue.generation, nullifierValue, input.recipient, recipientA, recipientB, result.proof, result.publicInputs, this.sdk.programId),
276
+ ], {
277
+ requireVersioned: true,
278
+ onPrepared: async (transaction, lastValidBlockHeight) => { const journal = this.journal(); if (journal)
279
+ await journal.markPrepared(operationId, transaction, lastValidBlockHeight); },
280
+ onSubmitted: async (signature) => this.submitted(operationId, signature, note),
281
+ });
282
+ if (outcome.status === "finalized-success") {
283
+ await this.finalized(operationId, note);
284
+ return outcome.signature;
285
+ }
286
+ if (outcome.status === "finalized-failed") {
287
+ await this.fail(operationId, note, String(outcome.error), journaled);
288
+ throw new Error(`Unshield transaction failed: ${String(outcome.error)}`);
289
+ }
290
+ await this.unknown(operationId, outcome.reason, outcome.signature);
291
+ throw new Error(`Unshield transaction outcome is unknown: ${outcome.reason}`);
292
+ }
293
+ catch (error) {
294
+ if (!String(error).includes("outcome is unknown") && !String(error).includes("TransactionUnknownError"))
295
+ await this.fail(operationId, reserved ? note : undefined, String(error), journaled);
296
+ throw error;
297
+ }
298
+ }
299
+ async privateSend(input) {
300
+ return this.unshield(input);
301
+ }
302
+ async privateSwap(input) {
303
+ const { prover, witness } = this.requireBackend();
304
+ this.sdk.assertPrivateTransactionReady();
305
+ const operationId = cryptoRandom(32);
306
+ let note = await this.select(input.pool, input.inputMint, input.amountIn);
307
+ let journaled = false;
308
+ let reserved = false;
309
+ try {
310
+ if (input.amountIn > note.amount)
311
+ throw new Error("Insufficient private balance");
312
+ note = await this.reserveAndBegin(note, { id: operationId, kind: "private_swap", state: "intent", pool: input.pool, inputCommitment: note.commitment, metadata: { assetIn: input.inputMint.toBase58(), assetOut: input.outputMint.toBase58(), amountIn: input.amountIn.toString() }, outputNotes: [] });
313
+ journaled = true;
314
+ reserved = true;
315
+ const pool = await this.sdk.getPool(input.pool);
316
+ const stateAccount = await this.sdk.getShieldedState(input.pool);
317
+ const direction = input.inputMint.equals(pool.tokenAMint) && input.outputMint.equals(pool.tokenBMint) ? 0 : input.inputMint.equals(pool.tokenBMint) && input.outputMint.equals(pool.tokenAMint) ? 1 : -1;
318
+ if (direction < 0)
319
+ throw new Error("Input and output mints must be the pool assets");
320
+ const [reserves, lpSupply] = await Promise.all([this.sdk.getReserves(pool), this.sdk.getLpSupply(pool)]);
321
+ const reserveIn = direction === 0 ? reserves.a : reserves.b;
322
+ const reserveOut = direction === 0 ? reserves.b : reserves.a;
323
+ const amountOut = swapOutputPreservingLpClaims(reserveIn, reserveOut, input.amountIn, pool.feeBps, lpSupply);
324
+ if (amountOut < input.minAmountOut)
325
+ throw new Error("Slippage exceeded");
326
+ const witnessValue = await witness.getWitness(input.pool, note.commitment);
327
+ const nullifierValue = nullifier(input.pool, input.inputMint, this.spendSecret, note.randomness);
328
+ const changeAmount = note.amount - input.amountIn;
329
+ const changeRandomness = changeAmount > 0n ? cryptoRandom(32) : new Uint8Array(32);
330
+ const changeCommitment = changeAmount > 0n ? noteCommitment(input.pool, input.inputMint, changeAmount, this.ownerCommitment, changeRandomness) : new Uint8Array(32);
331
+ const outputRandomness = cryptoRandom(32);
332
+ const outputCommitment = noteCommitment(input.pool, input.outputMint, amountOut, this.ownerCommitment, outputRandomness);
333
+ const changeNote = changeAmount > 0n ? this.localNote(input.pool, input.inputMint, changeAmount, changeRandomness, changeCommitment) : undefined;
334
+ const outputNote = this.localNote(input.pool, input.outputMint, amountOut, outputRandomness, outputCommitment);
335
+ await this.sdk.validatePrivateTransactionReady([
336
+ ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }),
337
+ privateSwapIx(this.sdk.wallet.publicKey, pool, stateAccount, direction, witnessValue.root, witnessValue.rootSequence, witnessValue.generation, nullifierValue, input.amountIn, amountOut, changeAmount, changeCommitment, outputCommitment, new Uint8Array(256), this.sdk.programId),
338
+ ]);
339
+ const journal = this.journal();
340
+ if (journal)
341
+ await journal.updateOperation(operationId, { metadata: { amountOut: amountOut.toString(), nullifier: key(nullifierValue), changeCommitment: key(changeCommitment), outputCommitment: key(outputCommitment) }, outputNotes: [...(changeNote ? [changeNote] : []), outputNote] });
342
+ const proofInput = { pool: input.pool, assetIn: input.inputMint, assetOut: input.outputMint, root: witnessValue.root, rootSequence: witnessValue.rootSequence, generation: witnessValue.generation, nullifier: nullifierValue, reserveIn, reserveOut, feeBps: pool.feeBps, amountIn: input.amountIn, amountOut, changeAmount, changeCommitment, outputCommitment, direction, swapNonce: pool.swapNonce, inputSpendSecret: this.spendSecret, inputRandomness: note.randomness, witness: witnessValue, changeSpendSecret: this.spendSecret, changeRandomness, outputSpendSecret: this.spendSecret, outputRandomness };
343
+ const result = await prover.provePrivateSwap(proofInput);
344
+ const expected = encodePrivateSwapPublicInputs(proofInput);
345
+ if (!Buffer.from(result.publicInputs).equals(Buffer.from(expected)))
346
+ throw new Error("Prover returned mismatched private-swap public inputs");
347
+ const outcome = await this.sdk.buildAndSendOutcome([
348
+ ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }),
349
+ privateSwapIx(this.sdk.wallet.publicKey, pool, stateAccount, direction, witnessValue.root, witnessValue.rootSequence, witnessValue.generation, nullifierValue, input.amountIn, amountOut, changeAmount, changeCommitment, outputCommitment, result.proof, this.sdk.programId),
350
+ ], {
351
+ requireVersioned: true,
352
+ onPrepared: async (transaction, lastValidBlockHeight) => { const journal = this.journal(); if (journal)
353
+ await journal.markPrepared(operationId, transaction, lastValidBlockHeight); },
354
+ onSubmitted: async (signature) => this.submitted(operationId, signature, note),
355
+ });
356
+ if (outcome.status === "finalized-success") {
357
+ await this.finalized(operationId, note, [...(changeNote ? [changeNote] : []), outputNote]);
358
+ return outcome.signature;
359
+ }
360
+ if (outcome.status === "finalized-failed") {
361
+ await this.fail(operationId, note, String(outcome.error), journaled);
362
+ throw new Error(`Private swap transaction failed: ${String(outcome.error)}`);
363
+ }
364
+ await this.unknown(operationId, outcome.reason, outcome.signature);
365
+ throw new Error(`Private swap transaction outcome is unknown: ${outcome.reason}`);
366
+ }
367
+ catch (error) {
368
+ if (!String(error).includes("outcome is unknown") && !String(error).includes("TransactionUnknownError"))
369
+ await this.fail(operationId, reserved ? note : undefined, String(error), journaled);
370
+ throw error;
371
+ }
372
+ }
373
+ localNote(pool, asset, amount, randomness, commitment) {
374
+ return { pool, asset, amount, ownerCommitment: this.ownerCommitment, randomness, commitment, encryptedPayload: encryptNote(encodeNote(pool, asset, amount, this.ownerCommitment, randomness), this.viewKey, pool, asset, commitment), state: "available" };
375
+ }
376
+ async recoverShieldedNotes(pool) {
377
+ const provider = this.witnessProvider;
378
+ if (!provider?.getShieldEvents || !provider.getSpentNullifiers)
379
+ throw new Error("The configured witness provider does not support authenticated shield recovery");
380
+ const stateAccount = await this.sdk.getShieldedState(pool);
381
+ const spentNullifiers = new Set((await provider.getSpentNullifiers(pool)).map(value => key(value)));
382
+ const existing = new Map((await this.getNotes()).filter(note => note.pool.equals(pool)).map(note => [key(note.commitment), note]));
383
+ const events = await provider.getShieldEvents(pool);
384
+ const decoded = [];
385
+ for (const event of events) {
386
+ const mint = event.asset === 0 ? stateAccount.tokenAMint : event.asset === 1 ? stateAccount.tokenBMint : undefined;
387
+ if (!mint)
388
+ throw new Error("Invalid shield asset in recovery history");
389
+ let note;
390
+ try {
391
+ note = decryptNotePayload(event.encryptedNote, this.viewKey, pool, mint, event.commitment);
392
+ }
393
+ catch {
394
+ // Shared pools contain envelopes for other wallets; their view-key authentication must fail closed and be skipped.
395
+ continue;
396
+ }
397
+ if (!same(note.ownerCommitment, this.ownerCommitment))
398
+ continue;
399
+ decoded.push({ event, mint, randomness: note.randomness, ownerCommitment: note.ownerCommitment, amount: note.amount, nullifier: nullifier(pool, mint, this.spendSecret, note.randomness) });
400
+ }
401
+ const getMultipleAccountsInfo = this.sdk.connection.getMultipleAccountsInfo?.bind(this.sdk.connection);
402
+ if (decoded.length && !getMultipleAccountsInfo)
403
+ throw new Error("RPC does not support finalized spent-nullifier verification");
404
+ const spentDiscriminator = accountDiscriminator("SpentNullifier");
405
+ for (let offset = 0; offset < decoded.length; offset += 100) {
406
+ const batch = decoded.slice(offset, offset + 100);
407
+ const addresses = batch.map(value => pda.spent(pool, value.nullifier, this.sdk.programId)[0]);
408
+ const accounts = await getMultipleAccountsInfo(addresses, "finalized");
409
+ if (accounts.length !== batch.length)
410
+ throw new Error("RPC returned an incomplete spent-nullifier response");
411
+ for (let index = 0; index < accounts.length; index++) {
412
+ const account = accounts[index];
413
+ if (!account)
414
+ continue;
415
+ const data = account.data;
416
+ if (!account.owner.equals(this.sdk.programId) || data.length !== 73 || !data.subarray(0, 8).equals(spentDiscriminator) || !data.subarray(8, 40).equals(pool.toBuffer()) || !data.subarray(40, 72).equals(Buffer.from(batch[index].nullifier)) || data[72] !== 1)
417
+ throw new Error("Invalid spent-nullifier account");
418
+ spentNullifiers.add(key(batch[index].nullifier));
419
+ }
420
+ }
421
+ const recovered = [];
422
+ for (const candidate of decoded) {
423
+ const { event, mint } = candidate;
424
+ const consumed = spentNullifiers.has(key(candidate.nullifier));
425
+ const note = { pool, asset: mint, amount: candidate.amount, ownerCommitment: candidate.ownerCommitment, randomness: candidate.randomness, commitment: event.commitment, encryptedPayload: event.encryptedNote, leafIndex: event.index, state: consumed ? "spent" : "available" };
426
+ const current = existing.get(key(note.commitment));
427
+ if (!current)
428
+ await this.store.saveNote(note);
429
+ else if (consumed && state(current) !== "spent") {
430
+ if (state(current) === "available")
431
+ await this.store.reserveNote(current.commitment, cryptoRandom(32), this.ownerCommitment);
432
+ await this.store.markSpent(current.commitment);
433
+ }
434
+ recovered.push(note);
435
+ }
436
+ return recovered;
437
+ }
438
+ async reconcilePending() {
439
+ const journal = this.journal();
440
+ if (!journal)
441
+ return [];
442
+ const pending = await journal.getPendingOperations();
443
+ for (const operation of pending) {
444
+ const signature = operation.signature ?? operation.metadata.signature ?? (operation.signedTransaction ? transactionSignatureFromBytes(operation.signedTransaction) : undefined);
445
+ if (!signature) {
446
+ if (operation.state === "intent") {
447
+ if (operation.inputCommitment) {
448
+ try {
449
+ await this.store.releaseReservation(operation.inputCommitment, operation.id);
450
+ }
451
+ catch { /* The composite reservation may not have committed. */ }
452
+ }
453
+ try {
454
+ await journal.markOperationFailed(operation.id, "Operation had no submitted transaction signature");
455
+ }
456
+ catch { /* Retry on the next reconciliation pass. */ }
457
+ }
458
+ continue;
459
+ }
460
+ if (!operation.signature && !operation.metadata.signature) {
461
+ try {
462
+ await journal.updateOperation(operation.id, { metadata: { signature } });
463
+ }
464
+ catch { /* Keep the operation pending for the next reconciliation pass. */ }
465
+ }
466
+ const outcome = await this.sdk.reconcileTransaction(signature, operation.lastValidBlockHeight === undefined ? undefined : Number(operation.lastValidBlockHeight));
467
+ if (outcome.status === "finalized-success") {
468
+ const eventName = operation.kind === "shield" ? "ShieldedNoteAppended" : operation.kind === "unshield" ? "Unshielded" : "PrivateSwapped";
469
+ const needles = Object.values(operation.metadata).filter(value => /^[0-9a-f]{64}$/i.test(value)).map(value => Uint8Array.from(Buffer.from(value, "hex")));
470
+ if (!(await this.sdk.hasFinalizedProgramEvent(signature, eventName, needles))) {
471
+ await journal.markOperationUnknown(operation.id, "Finalized transaction has no authenticated matching event");
472
+ continue;
473
+ }
474
+ if (operation.inputCommitment)
475
+ await this.store.markSpent(operation.inputCommitment, operation.id);
476
+ for (const note of operation.outputNotes)
477
+ await this.store.saveNote(note);
478
+ await journal.markOperationFinalized(operation.id);
479
+ }
480
+ else if (outcome.status === "finalized-failed") {
481
+ if (operation.inputCommitment)
482
+ await this.store.releaseReservation(operation.inputCommitment, operation.id);
483
+ await journal.markOperationFailed(operation.id, String(outcome.error));
484
+ }
485
+ else {
486
+ await journal.markOperationUnknown(operation.id, outcome.reason);
487
+ }
488
+ }
489
+ return journal.getPendingOperations();
490
+ }
491
+ }
@@ -0,0 +1,71 @@
1
+ import { PublicKey, type Connection } from "@solana/web3.js";
2
+ import type { MerkleCheckpointStore, MerkleWitness, MerkleWitnessProvider, TreeState } from "./types.js";
3
+ export interface ShieldAppendEvent {
4
+ kind: "shield";
5
+ pool: PublicKey;
6
+ asset: number;
7
+ amount: bigint;
8
+ commitment: Uint8Array;
9
+ encryptedNote: Uint8Array;
10
+ root: Uint8Array;
11
+ generation: bigint;
12
+ index: bigint;
13
+ sequence: bigint;
14
+ slot: number;
15
+ signature: string;
16
+ }
17
+ export interface PrivateSwapEvent {
18
+ kind: "swap";
19
+ pool: PublicKey;
20
+ direction: number;
21
+ amountIn: bigint;
22
+ amountOut: bigint;
23
+ root: Uint8Array;
24
+ rootSequence: bigint;
25
+ generation: bigint;
26
+ nullifier: Uint8Array;
27
+ changeCommitment: Uint8Array;
28
+ outputCommitment: Uint8Array;
29
+ changeIndex?: bigint;
30
+ outputIndex: bigint;
31
+ slot: number;
32
+ signature: string;
33
+ }
34
+ export interface UnshieldEvent {
35
+ kind: "unshield";
36
+ pool: PublicKey;
37
+ asset: number;
38
+ amount: bigint;
39
+ recipient: PublicKey;
40
+ nullifier: Uint8Array;
41
+ generation: bigint;
42
+ rootSequence: bigint;
43
+ slot: number;
44
+ signature: string;
45
+ }
46
+ export type ShieldedAppendEvent = ShieldAppendEvent | PrivateSwapEvent;
47
+ export type MerkleReconstructionErrorCode = "INVALID_CHECKPOINT" | "HISTORY_GAP" | "HISTORY_RPC" | "GENERATION_MISMATCH" | "SEQUENCE_GAP" | "ROOT_MISMATCH" | "TRANSACTION_MISSING" | "FAILED_TRANSACTION" | "DUPLICATE_EVENT" | "INVALID_TREE";
48
+ export declare class MerkleReconstructionError extends Error {
49
+ readonly code: MerkleReconstructionErrorCode;
50
+ constructor(code: MerkleReconstructionErrorCode, message: string);
51
+ }
52
+ export declare class RpcMerkleWitnessProvider implements MerkleWitnessProvider {
53
+ private readonly connection;
54
+ private readonly programId;
55
+ private readonly getTree;
56
+ private readonly checkpointStore?;
57
+ private readonly states;
58
+ private readonly syncing;
59
+ private genesisHash?;
60
+ constructor(connection: Connection, programId: PublicKey, getTree: (pool: PublicKey) => Promise<TreeState>, checkpointStore?: MerkleCheckpointStore | undefined);
61
+ private getGenesisIdentity;
62
+ private identity;
63
+ private loadCheckpoint;
64
+ private collectRows;
65
+ private transaction;
66
+ private syncPool;
67
+ private load;
68
+ getShieldEvents(pool: PublicKey): Promise<ShieldAppendEvent[]>;
69
+ getSpentNullifiers(pool: PublicKey): Promise<Uint8Array[]>;
70
+ getWitness(pool: PublicKey, commitment: Uint8Array): Promise<MerkleWitness>;
71
+ }