@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/store.js
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat, truncate, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
5
|
+
import { PublicKey } from "@solana/web3.js";
|
|
6
|
+
import { noteCommitment, noteStoreKey } from "./crypto.js";
|
|
7
|
+
const MAGIC = Buffer.from("LNSJ");
|
|
8
|
+
const FORMAT_VERSION = 1;
|
|
9
|
+
const AAD_PREFIX = Buffer.from("zkcpmm-v2/note-store/v1");
|
|
10
|
+
const ZERO_DIGEST = new Uint8Array(32);
|
|
11
|
+
const MAX_FRAME = 2 * 1024 * 1024;
|
|
12
|
+
const CHECKPOINT_MAGIC = Buffer.from("LMCP");
|
|
13
|
+
const CHECKPOINT_VERSION = 1;
|
|
14
|
+
const CHECKPOINT_AAD_PREFIX = Buffer.from("zkcpmm-v2/merkle-checkpoint/v1");
|
|
15
|
+
const MAX_CHECKPOINT = 64 * 1024 * 1024;
|
|
16
|
+
const LOCK_STALE_MS = 30_000;
|
|
17
|
+
const hex = (value) => Buffer.from(value).toString("hex");
|
|
18
|
+
const bytes = (value) => Uint8Array.from(Buffer.from(value, "hex"));
|
|
19
|
+
const same = (a, b) => Buffer.from(a).equals(Buffer.from(b));
|
|
20
|
+
const cloneBytes = (value) => value ? Uint8Array.from(value) : undefined;
|
|
21
|
+
const idOf = (value) => hex(value);
|
|
22
|
+
function cloneNote(note) {
|
|
23
|
+
return {
|
|
24
|
+
...note,
|
|
25
|
+
ownerCommitment: Uint8Array.from(note.ownerCommitment),
|
|
26
|
+
randomness: Uint8Array.from(note.randomness),
|
|
27
|
+
commitment: Uint8Array.from(note.commitment),
|
|
28
|
+
encryptedPayload: cloneBytes(note.encryptedPayload),
|
|
29
|
+
operationId: cloneBytes(note.operationId),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function noteState(note) {
|
|
33
|
+
if (note.state)
|
|
34
|
+
return note.state;
|
|
35
|
+
return note.spent ? "spent" : "available";
|
|
36
|
+
}
|
|
37
|
+
function serializeNote(note) {
|
|
38
|
+
return {
|
|
39
|
+
pool: note.pool.toBase58(),
|
|
40
|
+
asset: note.asset.toBase58(),
|
|
41
|
+
amount: note.amount.toString(),
|
|
42
|
+
ownerCommitment: hex(note.ownerCommitment),
|
|
43
|
+
randomness: hex(note.randomness),
|
|
44
|
+
commitment: hex(note.commitment),
|
|
45
|
+
encryptedPayload: note.encryptedPayload ? hex(note.encryptedPayload) : undefined,
|
|
46
|
+
leafIndex: note.leafIndex?.toString(),
|
|
47
|
+
state: noteState(note),
|
|
48
|
+
operationId: note.operationId ? hex(note.operationId) : undefined,
|
|
49
|
+
transactionSignature: note.transactionSignature,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function deserializeNote(value) {
|
|
53
|
+
const note = {
|
|
54
|
+
pool: new PublicKey(String(value.pool)),
|
|
55
|
+
asset: new PublicKey(String(value.asset)),
|
|
56
|
+
amount: BigInt(String(value.amount)),
|
|
57
|
+
ownerCommitment: bytes(String(value.ownerCommitment)),
|
|
58
|
+
randomness: bytes(String(value.randomness)),
|
|
59
|
+
commitment: bytes(String(value.commitment)),
|
|
60
|
+
encryptedPayload: typeof value.encryptedPayload === "string" ? bytes(value.encryptedPayload) : undefined,
|
|
61
|
+
leafIndex: value.leafIndex === undefined ? undefined : BigInt(String(value.leafIndex)),
|
|
62
|
+
state: String(value.state),
|
|
63
|
+
operationId: typeof value.operationId === "string" ? bytes(value.operationId) : undefined,
|
|
64
|
+
transactionSignature: typeof value.transactionSignature === "string" ? value.transactionSignature : undefined,
|
|
65
|
+
};
|
|
66
|
+
note.spent = note.state === "spent";
|
|
67
|
+
return note;
|
|
68
|
+
}
|
|
69
|
+
function serializeOperation(operation) {
|
|
70
|
+
return {
|
|
71
|
+
id: hex(operation.id),
|
|
72
|
+
kind: operation.kind,
|
|
73
|
+
state: operation.state,
|
|
74
|
+
pool: operation.pool.toBase58(),
|
|
75
|
+
inputCommitment: operation.inputCommitment ? hex(operation.inputCommitment) : undefined,
|
|
76
|
+
signature: operation.signature,
|
|
77
|
+
lastValidBlockHeight: operation.lastValidBlockHeight?.toString(),
|
|
78
|
+
signedTransaction: operation.signedTransaction ? hex(operation.signedTransaction) : undefined,
|
|
79
|
+
metadata: operation.metadata,
|
|
80
|
+
outputNotes: operation.outputNotes.map(serializeNote),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function deserializeOperation(value) {
|
|
84
|
+
return {
|
|
85
|
+
id: bytes(String(value.id)),
|
|
86
|
+
kind: value.kind,
|
|
87
|
+
state: value.state,
|
|
88
|
+
pool: new PublicKey(String(value.pool)),
|
|
89
|
+
inputCommitment: typeof value.inputCommitment === "string" ? bytes(value.inputCommitment) : undefined,
|
|
90
|
+
signature: typeof value.signature === "string" ? value.signature : undefined,
|
|
91
|
+
lastValidBlockHeight: value.lastValidBlockHeight === undefined ? undefined : BigInt(String(value.lastValidBlockHeight)),
|
|
92
|
+
signedTransaction: typeof value.signedTransaction === "string" ? bytes(value.signedTransaction) : undefined,
|
|
93
|
+
metadata: (value.metadata ?? {}),
|
|
94
|
+
outputNotes: Array.isArray(value.outputNotes) ? value.outputNotes.map(item => deserializeNote(item)) : [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export class EncryptedFileNoteStore {
|
|
98
|
+
path;
|
|
99
|
+
encryptionKey;
|
|
100
|
+
owner;
|
|
101
|
+
lockPath;
|
|
102
|
+
notes = new Map();
|
|
103
|
+
operations = new Map();
|
|
104
|
+
sequence = 0n;
|
|
105
|
+
previousDigest = Uint8Array.from(ZERO_DIGEST);
|
|
106
|
+
constructor(path, encryptionKey, owner) {
|
|
107
|
+
this.path = path;
|
|
108
|
+
this.encryptionKey = encryptionKey;
|
|
109
|
+
this.owner = owner;
|
|
110
|
+
if (encryptionKey.length !== 32 || owner.length !== 32)
|
|
111
|
+
throw new Error("Note store key and owner must be 32 bytes");
|
|
112
|
+
this.lockPath = `${path}.lock`;
|
|
113
|
+
}
|
|
114
|
+
static fromSeed(path, seed, owner) {
|
|
115
|
+
return new EncryptedFileNoteStore(path, noteStoreKey(seed), owner);
|
|
116
|
+
}
|
|
117
|
+
validateNote(note) {
|
|
118
|
+
if (note.state !== undefined && !["available", "reserved", "submitted", "spent"].includes(note.state))
|
|
119
|
+
throw new Error("Invalid note state");
|
|
120
|
+
if (note.amount <= 0n || note.ownerCommitment.length !== 32 || note.randomness.length !== 32 || note.commitment.length !== 32)
|
|
121
|
+
throw new Error("Invalid note");
|
|
122
|
+
if (!Buffer.from(noteCommitment(note.pool, note.asset, note.amount, note.ownerCommitment, note.randomness)).equals(Buffer.from(note.commitment)))
|
|
123
|
+
throw new Error("Note commitment mismatch");
|
|
124
|
+
if (!Buffer.from(note.ownerCommitment).equals(Buffer.from(this.owner)))
|
|
125
|
+
throw new Error("Note belongs to another wallet");
|
|
126
|
+
if (note.encryptedPayload && note.encryptedPayload.length !== 186)
|
|
127
|
+
throw new Error("Invalid encrypted note payload");
|
|
128
|
+
return { ...cloneNote(note), state: noteState(note), spent: noteState(note) === "spent" };
|
|
129
|
+
}
|
|
130
|
+
apply(event) {
|
|
131
|
+
if (event.type === "save_note" && event.note) {
|
|
132
|
+
const note = this.validateNote(deserializeNote(event.note));
|
|
133
|
+
const key = idOf(note.commitment);
|
|
134
|
+
const current = this.notes.get(key);
|
|
135
|
+
if (current && JSON.stringify(serializeNote(current)) !== JSON.stringify(serializeNote(note)))
|
|
136
|
+
throw new Error("Conflicting duplicate note commitment");
|
|
137
|
+
this.notes.set(key, note);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (event.type === "reserve_note" && event.id) {
|
|
141
|
+
const note = this.notes.get(event.id);
|
|
142
|
+
if (!note)
|
|
143
|
+
throw new Error("Note is not available");
|
|
144
|
+
if (noteState(note) === "reserved" && note.operationId && same(note.operationId, bytes(String(event.signature))))
|
|
145
|
+
return;
|
|
146
|
+
if (noteState(note) === "submitted" && note.operationId && same(note.operationId, bytes(String(event.signature))))
|
|
147
|
+
return;
|
|
148
|
+
if (noteState(note) !== "available")
|
|
149
|
+
throw new Error("Note is not available");
|
|
150
|
+
note.state = "reserved";
|
|
151
|
+
note.spent = false;
|
|
152
|
+
note.operationId = bytes(String(event.signature));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (event.type === "submit_note" && event.id && event.signature) {
|
|
156
|
+
const note = this.notes.get(event.id);
|
|
157
|
+
if (!note)
|
|
158
|
+
throw new Error("Note is not reserved");
|
|
159
|
+
if (noteState(note) === "submitted" && note.transactionSignature === event.signature)
|
|
160
|
+
return;
|
|
161
|
+
if (noteState(note) !== "reserved")
|
|
162
|
+
throw new Error("Note is not reserved");
|
|
163
|
+
note.state = "submitted";
|
|
164
|
+
note.transactionSignature = event.signature;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (event.type === "release_note" && event.id) {
|
|
168
|
+
const note = this.notes.get(event.id);
|
|
169
|
+
if (!note || noteState(note) === "spent")
|
|
170
|
+
throw new Error("Note cannot be released");
|
|
171
|
+
if (noteState(note) === "available")
|
|
172
|
+
return;
|
|
173
|
+
note.state = "available";
|
|
174
|
+
note.spent = false;
|
|
175
|
+
note.operationId = undefined;
|
|
176
|
+
note.transactionSignature = undefined;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (event.type === "spend_note" && event.id) {
|
|
180
|
+
const note = this.notes.get(event.id);
|
|
181
|
+
if (note && noteState(note) === "spent")
|
|
182
|
+
return;
|
|
183
|
+
if (!note || (noteState(note) !== "submitted" && noteState(note) !== "reserved"))
|
|
184
|
+
throw new Error("Note is not pending spend");
|
|
185
|
+
note.state = "spent";
|
|
186
|
+
note.spent = true;
|
|
187
|
+
note.operationId = undefined;
|
|
188
|
+
if (event.signature)
|
|
189
|
+
note.transactionSignature = event.signature;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (event.type === "reserve_and_begin" && event.id && event.signature && event.operation) {
|
|
193
|
+
const note = this.notes.get(event.id);
|
|
194
|
+
const operation = deserializeOperation(event.operation);
|
|
195
|
+
if (!note || !Buffer.from(note.ownerCommitment).equals(Buffer.from(this.owner)) || noteState(note) !== "available")
|
|
196
|
+
throw new Error("Note is not available");
|
|
197
|
+
if (idOf(operation.id) !== event.signature || !operation.inputCommitment || idOf(operation.inputCommitment) !== event.id)
|
|
198
|
+
throw new Error("Invalid reserve-and-begin operation");
|
|
199
|
+
if (this.operations.has(idOf(operation.id)))
|
|
200
|
+
throw new Error("Duplicate operation ID in journal");
|
|
201
|
+
operation.outputNotes = operation.outputNotes.map(value => this.validateNote(value));
|
|
202
|
+
note.state = "reserved";
|
|
203
|
+
note.spent = false;
|
|
204
|
+
note.operationId = Uint8Array.from(operation.id);
|
|
205
|
+
this.operations.set(idOf(operation.id), operation);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (event.type === "operation_begin" && event.operation) {
|
|
209
|
+
const operation = deserializeOperation(event.operation);
|
|
210
|
+
if (this.operations.has(idOf(operation.id)))
|
|
211
|
+
throw new Error("Duplicate operation ID in journal");
|
|
212
|
+
operation.outputNotes = operation.outputNotes.map(note => this.validateNote(note));
|
|
213
|
+
this.operations.set(idOf(operation.id), operation);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (event.type === "operation_prepared" && event.id) {
|
|
217
|
+
const operation = this.operations.get(event.id);
|
|
218
|
+
if (!operation)
|
|
219
|
+
throw new Error("Unknown operation");
|
|
220
|
+
operation.state = "prepared";
|
|
221
|
+
operation.signedTransaction = event.signedTransaction ? bytes(event.signedTransaction) : undefined;
|
|
222
|
+
operation.lastValidBlockHeight = event.lastValidBlockHeight ? BigInt(event.lastValidBlockHeight) : undefined;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (event.type === "operation_update" && event.id && event.operation) {
|
|
226
|
+
const operation = this.operations.get(event.id);
|
|
227
|
+
if (!operation)
|
|
228
|
+
throw new Error("Unknown operation");
|
|
229
|
+
const patch = deserializeOperation(event.operation);
|
|
230
|
+
operation.metadata = { ...operation.metadata, ...patch.metadata };
|
|
231
|
+
operation.outputNotes = patch.outputNotes.map(note => this.validateNote(note));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (event.type === "operation_submitted" && event.id && event.signature) {
|
|
235
|
+
const operation = this.operations.get(event.id);
|
|
236
|
+
if (!operation)
|
|
237
|
+
throw new Error("Unknown operation");
|
|
238
|
+
operation.state = "submitted";
|
|
239
|
+
operation.signature = event.signature;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (event.type === "operation_finalized" && event.id) {
|
|
243
|
+
const operation = this.operations.get(event.id);
|
|
244
|
+
if (!operation)
|
|
245
|
+
throw new Error("Unknown operation");
|
|
246
|
+
if (operation.state === "finalized")
|
|
247
|
+
return;
|
|
248
|
+
operation.state = "finalized";
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (event.type === "operation_failed" && event.id) {
|
|
252
|
+
const operation = this.operations.get(event.id);
|
|
253
|
+
if (!operation)
|
|
254
|
+
throw new Error("Unknown operation");
|
|
255
|
+
if (operation.state === "failed")
|
|
256
|
+
return;
|
|
257
|
+
operation.state = "failed";
|
|
258
|
+
operation.metadata.failure = event.reason ?? "transaction failed";
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (event.type === "operation_unknown" && event.id) {
|
|
262
|
+
const operation = this.operations.get(event.id);
|
|
263
|
+
if (!operation)
|
|
264
|
+
throw new Error("Unknown operation");
|
|
265
|
+
if (operation.state === "unknown")
|
|
266
|
+
return;
|
|
267
|
+
operation.state = "unknown";
|
|
268
|
+
operation.metadata.unknown = event.reason ?? "transaction outcome unknown";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async readJournal(repairPartial = false) {
|
|
272
|
+
this.notes.clear();
|
|
273
|
+
this.operations.clear();
|
|
274
|
+
this.sequence = 0n;
|
|
275
|
+
this.previousDigest = Uint8Array.from(ZERO_DIGEST);
|
|
276
|
+
let data;
|
|
277
|
+
try {
|
|
278
|
+
data = await readFile(this.path);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error.code === "ENOENT")
|
|
282
|
+
return;
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
if (data.length < 5 || !data.subarray(0, 4).equals(MAGIC) || data[4] !== FORMAT_VERSION)
|
|
286
|
+
throw new Error("Invalid note store header");
|
|
287
|
+
let offset = 5;
|
|
288
|
+
let validOffset = offset;
|
|
289
|
+
while (offset < data.length) {
|
|
290
|
+
if (data.length - offset < 4)
|
|
291
|
+
break;
|
|
292
|
+
const frameLength = data.readUInt32LE(offset);
|
|
293
|
+
if (frameLength > MAX_FRAME || frameLength < 8 + 32 + 24 + 16)
|
|
294
|
+
throw new Error("Invalid note store frame length");
|
|
295
|
+
if (data.length - offset - 4 < frameLength)
|
|
296
|
+
break;
|
|
297
|
+
const frameStart = offset;
|
|
298
|
+
const body = data.subarray(offset + 4, offset + 4 + frameLength);
|
|
299
|
+
const sequence = body.readBigUInt64LE(0);
|
|
300
|
+
const previous = body.subarray(8, 40);
|
|
301
|
+
const nonce = body.subarray(40, 64);
|
|
302
|
+
const ciphertext = body.subarray(64);
|
|
303
|
+
if (sequence !== this.sequence + 1n || !Buffer.from(previous).equals(Buffer.from(this.previousDigest)))
|
|
304
|
+
throw new Error("Invalid note store journal chain");
|
|
305
|
+
const aad = Buffer.concat([AAD_PREFIX, Buffer.from(previous), Buffer.alloc(8)]);
|
|
306
|
+
aad.writeBigUInt64LE(sequence, AAD_PREFIX.length + 32);
|
|
307
|
+
const plaintext = xchacha20poly1305(this.encryptionKey, nonce, aad).decrypt(ciphertext);
|
|
308
|
+
const event = JSON.parse(Buffer.from(plaintext).toString("utf8"));
|
|
309
|
+
this.apply(event);
|
|
310
|
+
this.sequence = sequence;
|
|
311
|
+
this.previousDigest = createHash("sha256").update(data.subarray(frameStart, offset + 4 + frameLength)).digest();
|
|
312
|
+
offset += 4 + frameLength;
|
|
313
|
+
validOffset = offset;
|
|
314
|
+
}
|
|
315
|
+
if (repairPartial && validOffset !== data.length)
|
|
316
|
+
await truncate(this.path, validOffset);
|
|
317
|
+
}
|
|
318
|
+
async acquireLock() {
|
|
319
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
320
|
+
const started = Date.now();
|
|
321
|
+
for (;;) {
|
|
322
|
+
try {
|
|
323
|
+
const handle = await open(this.lockPath, "wx", 0o600);
|
|
324
|
+
await handle.writeFile(String(process.pid));
|
|
325
|
+
await handle.sync();
|
|
326
|
+
await handle.close();
|
|
327
|
+
return async () => { await rm(this.lockPath, { force: true }); };
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (error.code !== "EEXIST")
|
|
331
|
+
throw error;
|
|
332
|
+
let stale = false;
|
|
333
|
+
try {
|
|
334
|
+
const lock = (await readFile(this.lockPath, "utf8")).trim();
|
|
335
|
+
const pid = Number(lock);
|
|
336
|
+
if (Number.isSafeInteger(pid) && pid > 0) {
|
|
337
|
+
try {
|
|
338
|
+
process.kill(pid, 0);
|
|
339
|
+
}
|
|
340
|
+
catch (probeError) {
|
|
341
|
+
stale = probeError.code === "ESRCH";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
stale = Date.now() - (await stat(this.lockPath)).mtimeMs > LOCK_STALE_MS;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (probeError) {
|
|
349
|
+
if (probeError.code === "ENOENT")
|
|
350
|
+
continue;
|
|
351
|
+
stale = Date.now() - (await stat(this.lockPath)).mtimeMs > LOCK_STALE_MS;
|
|
352
|
+
}
|
|
353
|
+
if (stale) {
|
|
354
|
+
await rm(this.lockPath, { force: true });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (Date.now() - started > 5_000)
|
|
358
|
+
throw new Error("Note store writer lock is busy");
|
|
359
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async append(events) {
|
|
364
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
365
|
+
let exists = true;
|
|
366
|
+
try {
|
|
367
|
+
await stat(this.path);
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (error.code === "ENOENT")
|
|
371
|
+
exists = false;
|
|
372
|
+
else
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
if (!exists) {
|
|
376
|
+
const temporary = `${this.path}.init-${process.pid}-${Date.now()}`;
|
|
377
|
+
const initializer = await open(temporary, "wx", 0o600);
|
|
378
|
+
try {
|
|
379
|
+
await initializer.write(Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION])]));
|
|
380
|
+
await initializer.sync();
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
await initializer.close();
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
await rename(temporary, this.path);
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await rm(temporary, { force: true });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const handle = await open(this.path, "a", 0o600);
|
|
393
|
+
try {
|
|
394
|
+
for (const event of events) {
|
|
395
|
+
const sequence = this.sequence + 1n;
|
|
396
|
+
const previous = this.previousDigest;
|
|
397
|
+
const aad = Buffer.concat([AAD_PREFIX, Buffer.from(previous), Buffer.alloc(8)]);
|
|
398
|
+
aad.writeBigUInt64LE(sequence, AAD_PREFIX.length + 32);
|
|
399
|
+
const nonce = cryptoRandom(24);
|
|
400
|
+
const ciphertext = xchacha20poly1305(this.encryptionKey, nonce, aad).encrypt(Buffer.from(JSON.stringify(event)));
|
|
401
|
+
const body = Buffer.alloc(8 + 32 + 24 + ciphertext.length);
|
|
402
|
+
body.writeBigUInt64LE(sequence, 0);
|
|
403
|
+
Buffer.from(previous).copy(body, 8);
|
|
404
|
+
Buffer.from(nonce).copy(body, 40);
|
|
405
|
+
Buffer.from(ciphertext).copy(body, 64);
|
|
406
|
+
const frame = Buffer.alloc(4 + body.length);
|
|
407
|
+
frame.writeUInt32LE(body.length, 0);
|
|
408
|
+
body.copy(frame, 4);
|
|
409
|
+
await handle.write(frame);
|
|
410
|
+
this.apply(event);
|
|
411
|
+
this.sequence = sequence;
|
|
412
|
+
this.previousDigest = createHash("sha256").update(frame).digest();
|
|
413
|
+
}
|
|
414
|
+
await handle.sync();
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
await handle.close();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async mutate(operation) {
|
|
421
|
+
const unlock = await this.acquireLock();
|
|
422
|
+
try {
|
|
423
|
+
await this.readJournal(true);
|
|
424
|
+
const { result, events } = await operation();
|
|
425
|
+
if (events.length)
|
|
426
|
+
await this.append(events);
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
await unlock();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async getNotes(ownerCommitment = this.owner) {
|
|
434
|
+
const unlock = await this.acquireLock();
|
|
435
|
+
try {
|
|
436
|
+
await this.readJournal(true);
|
|
437
|
+
if (!Buffer.from(ownerCommitment).equals(Buffer.from(this.owner)))
|
|
438
|
+
throw new Error("Note owner mismatch");
|
|
439
|
+
return [...this.notes.values()].map(cloneNote);
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
await unlock();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async saveNote(input) {
|
|
446
|
+
const note = this.validateNote(input);
|
|
447
|
+
await this.mutate(async () => {
|
|
448
|
+
const current = this.notes.get(idOf(note.commitment));
|
|
449
|
+
if (current) {
|
|
450
|
+
if (JSON.stringify(serializeNote(current)) !== JSON.stringify(serializeNote(note)))
|
|
451
|
+
throw new Error("Conflicting duplicate note commitment");
|
|
452
|
+
return { result: undefined, events: [] };
|
|
453
|
+
}
|
|
454
|
+
return { result: undefined, events: [{ type: "save_note", note: serializeNote(note) }] };
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
async reserveNote(commitment, operationId, ownerCommitment) {
|
|
458
|
+
if (!Buffer.from(ownerCommitment).equals(Buffer.from(this.owner)))
|
|
459
|
+
throw new Error("Note owner mismatch");
|
|
460
|
+
return this.mutate(async () => {
|
|
461
|
+
const note = this.notes.get(idOf(commitment));
|
|
462
|
+
if (!note || !Buffer.from(note.ownerCommitment).equals(Buffer.from(ownerCommitment)) || noteState(note) !== "available")
|
|
463
|
+
throw new Error("Note is not available");
|
|
464
|
+
return { result: cloneNote(note), events: [{ type: "reserve_note", id: idOf(commitment), signature: idOf(operationId) }] };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async reserveNoteAndBegin(commitment, operationId, ownerCommitment, operation) {
|
|
468
|
+
if (!Buffer.from(ownerCommitment).equals(Buffer.from(this.owner)))
|
|
469
|
+
throw new Error("Note owner mismatch");
|
|
470
|
+
return this.mutate(async () => {
|
|
471
|
+
const note = this.notes.get(idOf(commitment));
|
|
472
|
+
if (!note || !Buffer.from(note.ownerCommitment).equals(Buffer.from(ownerCommitment)) || noteState(note) !== "available")
|
|
473
|
+
throw new Error("Note is not available");
|
|
474
|
+
if (!Buffer.from(operation.id).equals(Buffer.from(operationId)) || !operation.inputCommitment || !Buffer.from(operation.inputCommitment).equals(Buffer.from(commitment)))
|
|
475
|
+
throw new Error("Invalid reserve-and-begin operation");
|
|
476
|
+
if (this.operations.has(idOf(operationId)))
|
|
477
|
+
throw new Error("Duplicate operation ID");
|
|
478
|
+
return { result: cloneNote(note), events: [{ type: "reserve_and_begin", id: idOf(commitment), signature: idOf(operationId), operation: serializeOperation(operation) }] };
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
async markSubmitted(commitment, operationId, signature) {
|
|
482
|
+
await this.mutate(async () => {
|
|
483
|
+
const note = this.notes.get(idOf(commitment));
|
|
484
|
+
if (note && noteState(note) === "submitted" && note.transactionSignature === signature)
|
|
485
|
+
return { result: undefined, events: [] };
|
|
486
|
+
if (!note || noteState(note) !== "reserved" || !note.operationId || !Buffer.from(note.operationId).equals(Buffer.from(operationId)))
|
|
487
|
+
throw new Error("Note reservation mismatch");
|
|
488
|
+
return { result: undefined, events: [{ type: "submit_note", id: idOf(commitment), signature }] };
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
async markSpent(commitment, operationId) {
|
|
492
|
+
await this.mutate(async () => {
|
|
493
|
+
const note = this.notes.get(idOf(commitment));
|
|
494
|
+
if (note && noteState(note) === "spent")
|
|
495
|
+
return { result: undefined, events: [] };
|
|
496
|
+
if (!note || (noteState(note) !== "submitted" && noteState(note) !== "reserved"))
|
|
497
|
+
throw new Error("Note is not pending spend");
|
|
498
|
+
if (operationId && (!note.operationId || !Buffer.from(note.operationId).equals(Buffer.from(operationId))))
|
|
499
|
+
throw new Error("Note reservation mismatch");
|
|
500
|
+
return { result: undefined, events: [{ type: "spend_note", id: idOf(commitment), signature: note.transactionSignature }] };
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
async releaseReservation(commitment, operationId) {
|
|
504
|
+
await this.mutate(async () => {
|
|
505
|
+
const note = this.notes.get(idOf(commitment));
|
|
506
|
+
if (note && noteState(note) === "available")
|
|
507
|
+
return { result: undefined, events: [] };
|
|
508
|
+
if (!note || (noteState(note) !== "reserved" && noteState(note) !== "submitted"))
|
|
509
|
+
throw new Error("Note is not reserved");
|
|
510
|
+
if (operationId && (!note.operationId || !Buffer.from(note.operationId).equals(Buffer.from(operationId))))
|
|
511
|
+
throw new Error("Note reservation mismatch");
|
|
512
|
+
return { result: undefined, events: [{ type: "release_note", id: idOf(commitment) }] };
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async beginOperation(operation) {
|
|
516
|
+
await this.mutate(async () => {
|
|
517
|
+
if (this.operations.has(idOf(operation.id)))
|
|
518
|
+
throw new Error("Duplicate operation ID");
|
|
519
|
+
return { result: undefined, events: [{ type: "operation_begin", operation: serializeOperation(operation) }] };
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
async updateOperation(id, patch) {
|
|
523
|
+
await this.mutate(async () => {
|
|
524
|
+
const operation = this.operations.get(idOf(id));
|
|
525
|
+
if (!operation)
|
|
526
|
+
throw new Error("Unknown operation");
|
|
527
|
+
const updated = { ...operation, metadata: { ...operation.metadata, ...(patch.metadata ?? {}) }, outputNotes: (patch.outputNotes ?? operation.outputNotes).map(cloneNote) };
|
|
528
|
+
return { result: undefined, events: [{ type: "operation_update", id: idOf(id), operation: serializeOperation(updated) }] };
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
async markPrepared(id, signedTransaction, lastValidBlockHeight) {
|
|
532
|
+
await this.mutate(async () => ({ result: undefined, events: [{ type: "operation_prepared", id: idOf(id), signedTransaction: hex(signedTransaction), lastValidBlockHeight: lastValidBlockHeight.toString() }] }));
|
|
533
|
+
}
|
|
534
|
+
async markOperationSubmitted(id, signature) {
|
|
535
|
+
await this.mutate(async () => ({ result: undefined, events: [{ type: "operation_submitted", id: idOf(id), signature }] }));
|
|
536
|
+
}
|
|
537
|
+
async markOperationFinalized(id) {
|
|
538
|
+
await this.mutate(async () => ({ result: undefined, events: [{ type: "operation_finalized", id: idOf(id) }] }));
|
|
539
|
+
}
|
|
540
|
+
async markOperationFailed(id, reason) {
|
|
541
|
+
await this.mutate(async () => ({ result: undefined, events: [{ type: "operation_failed", id: idOf(id), reason: reason.slice(0, 256) }] }));
|
|
542
|
+
}
|
|
543
|
+
async markOperationUnknown(id, reason) {
|
|
544
|
+
await this.mutate(async () => ({ result: undefined, events: [{ type: "operation_unknown", id: idOf(id), reason: reason.slice(0, 256) }] }));
|
|
545
|
+
}
|
|
546
|
+
async getPendingOperations() {
|
|
547
|
+
const unlock = await this.acquireLock();
|
|
548
|
+
try {
|
|
549
|
+
await this.readJournal(true);
|
|
550
|
+
return [...this.operations.values()].filter(operation => operation.state !== "finalized" && operation.state !== "failed").map(operation => ({ ...operation, id: Uint8Array.from(operation.id), outputNotes: operation.outputNotes.map(cloneNote), metadata: { ...operation.metadata } }));
|
|
551
|
+
}
|
|
552
|
+
finally {
|
|
553
|
+
await unlock();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async exportBackup() {
|
|
557
|
+
const unlock = await this.acquireLock();
|
|
558
|
+
try {
|
|
559
|
+
await this.readJournal(true);
|
|
560
|
+
try {
|
|
561
|
+
return Uint8Array.from(await readFile(this.path));
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
if (error.code === "ENOENT")
|
|
565
|
+
return Uint8Array.from(Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION])]));
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
await unlock();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async importBackup(backup) {
|
|
574
|
+
const temporary = `${this.path}.import-${process.pid}-${Date.now()}`;
|
|
575
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
576
|
+
await writeFile(temporary, backup, { mode: 0o600 });
|
|
577
|
+
try {
|
|
578
|
+
const candidate = new EncryptedFileNoteStore(temporary, this.encryptionKey, this.owner);
|
|
579
|
+
await candidate.getNotes();
|
|
580
|
+
const unlock = await this.acquireLock();
|
|
581
|
+
try {
|
|
582
|
+
await rename(temporary, this.path);
|
|
583
|
+
await this.readJournal(true);
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
await unlock();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
await rm(temporary, { force: true });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
checkpointPath(identity) {
|
|
594
|
+
const digest = createHash("sha256").update(Buffer.from("zkcpmm-merkle-checkpoint-path/v1\0")).update(identity).digest("hex");
|
|
595
|
+
return `${this.path}.merkle-${digest}`;
|
|
596
|
+
}
|
|
597
|
+
async loadMerkleCheckpoint(identity) {
|
|
598
|
+
const unlock = await this.acquireLock();
|
|
599
|
+
try {
|
|
600
|
+
let data;
|
|
601
|
+
try {
|
|
602
|
+
data = await readFile(this.checkpointPath(identity));
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
if (error.code === "ENOENT")
|
|
606
|
+
return undefined;
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
if (data.length < CHECKPOINT_MAGIC.length + 1 + 24 + 16 || !data.subarray(0, CHECKPOINT_MAGIC.length).equals(CHECKPOINT_MAGIC) || data[CHECKPOINT_MAGIC.length] !== CHECKPOINT_VERSION) {
|
|
610
|
+
throw new Error("Invalid Merkle checkpoint header");
|
|
611
|
+
}
|
|
612
|
+
const nonceOffset = CHECKPOINT_MAGIC.length + 1;
|
|
613
|
+
const nonce = data.subarray(nonceOffset, nonceOffset + 24);
|
|
614
|
+
const ciphertext = data.subarray(nonceOffset + 24);
|
|
615
|
+
if (ciphertext.length > MAX_CHECKPOINT + 16)
|
|
616
|
+
throw new Error("Merkle checkpoint is too large");
|
|
617
|
+
const aad = Buffer.concat([CHECKPOINT_AAD_PREFIX, Buffer.from(identity)]);
|
|
618
|
+
try {
|
|
619
|
+
return Uint8Array.from(xchacha20poly1305(this.encryptionKey, nonce, aad).decrypt(ciphertext));
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
throw new Error("Invalid Merkle checkpoint integrity");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
await unlock();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async saveMerkleCheckpoint(identity, checkpoint) {
|
|
630
|
+
if (checkpoint.length > MAX_CHECKPOINT)
|
|
631
|
+
throw new Error("Merkle checkpoint is too large");
|
|
632
|
+
const unlock = await this.acquireLock();
|
|
633
|
+
const path = this.checkpointPath(identity);
|
|
634
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
635
|
+
try {
|
|
636
|
+
const nonce = cryptoRandom(24);
|
|
637
|
+
const aad = Buffer.concat([CHECKPOINT_AAD_PREFIX, Buffer.from(identity)]);
|
|
638
|
+
const ciphertext = xchacha20poly1305(this.encryptionKey, nonce, aad).encrypt(checkpoint);
|
|
639
|
+
const encoded = Buffer.concat([CHECKPOINT_MAGIC, Buffer.from([CHECKPOINT_VERSION]), Buffer.from(nonce), Buffer.from(ciphertext)]);
|
|
640
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
641
|
+
try {
|
|
642
|
+
await handle.write(encoded);
|
|
643
|
+
await handle.sync();
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
await handle.close();
|
|
647
|
+
}
|
|
648
|
+
await rename(temporary, path);
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
await rm(temporary, { force: true });
|
|
652
|
+
await unlock();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function cryptoRandom(length) {
|
|
657
|
+
if (!globalThis.crypto)
|
|
658
|
+
throw new Error("Secure randomness unavailable");
|
|
659
|
+
const value = new Uint8Array(length);
|
|
660
|
+
globalThis.crypto.getRandomValues(value);
|
|
661
|
+
return value;
|
|
662
|
+
}
|