@zbase-protocol/core 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/notes.js ADDED
@@ -0,0 +1,547 @@
1
+ /**
2
+ * @zbase-protocol/core — UTXO note primitives (Shipment A.1 scaffold)
3
+ *
4
+ * v0: in-memory note model + viewing-key encryption for storing notes off-chain
5
+ * and posting their ciphertext alongside the spend's `NoteCommitted` event.
6
+ *
7
+ * Mirrors Railgun's note model (https://docs.railgun.org/wiki/learn/privacy-system).
8
+ * The on-chain commitment encoding is intentionally identical to the current
9
+ * single-value commitment in `account.ts` so the same Poseidon helpers stay in
10
+ * use and the Anchor program's `crypto.rs` continues to apply without change:
11
+ *
12
+ * precommitment = Poseidon2(nullifier, secret)
13
+ * commitment = Poseidon3(amount, label, precommitment)
14
+ *
15
+ * Cryptographic envelope chosen for cold-start performance over libsodium.js
16
+ * (no WASM init, no async load) — see /docs/utxo-notes-design.md §4.1.
17
+ *
18
+ * * X25519 key agreement (`@noble/curves/ed25519` → `x25519`)
19
+ * * XChaCha20-Poly1305 (`@noble/ciphers/chacha`)
20
+ * * 2-byte view-tag (Railgun-style fast scan filter)
21
+ *
22
+ * Status: scaffold. Do NOT use the encryption helpers in production until
23
+ * (a) the bytes layout has been pinned by a wallet review and
24
+ * (b) the trusted-setup ceremony for `note_spend.circom` has completed.
25
+ */
26
+ import { poseidon1, poseidon2, poseidon3 } from "poseidon-lite";
27
+ import { x25519 } from "@noble/curves/ed25519";
28
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
29
+ import { keccak_256 } from "@noble/hashes/sha3";
30
+ import { randomBytes } from "@noble/hashes/utils";
31
+ // Shared, unbiased (rejection-sampling) field sampler — see account.ts (audit F7).
32
+ import { randomFieldElement } from "./account.js";
33
+ // -----------------------------------------------------------------------------
34
+ // Note creation
35
+ // -----------------------------------------------------------------------------
36
+ /**
37
+ * Create a fresh note with random nullifier + secret.
38
+ *
39
+ * `amount` + `label` come from a deposit or a parent note being split. The NPK
40
+ * inputs (`spendingPK`, `viewingPKBlind`) identify the RECIPIENT and bind the
41
+ * label privately — they default to 0 here for the self-spend / scaffold case,
42
+ * but a real transfer MUST pass the recipient's derived NPK inputs.
43
+ *
44
+ * ⚠️ Deriving spendingPK/viewingPKBlind from a recipient's viewing key is the
45
+ * auditor-scoped gap (see the Note doc). Until that lands, callers either pass
46
+ * 0 (change-back-to-self placeholder) or supply explicit values.
47
+ */
48
+ export function createNote(amount, label, npk) {
49
+ return {
50
+ amount,
51
+ label,
52
+ spendingPK: npk?.spendingPK ?? 0n,
53
+ viewingPKBlind: npk?.viewingPKBlind ?? 0n,
54
+ nullifier: randomFieldElement(),
55
+ secret: randomFieldElement(),
56
+ };
57
+ }
58
+ /**
59
+ * Create a zero-value dummy note. Used as a placeholder input/output to fill
60
+ * the fixed N=2/M=2 slots of the v0 spend circuit when the spender does not
61
+ * actually need 2 inputs or 2 outputs.
62
+ *
63
+ * A dummy carries amount=0 and label=0; the circuit's `inIsDummy[i]` flag
64
+ * must be set to 1 for input dummies. (Output dummies are valid as-is because
65
+ * a 0-amount output commitment hashes deterministically and inserts harmlessly
66
+ * into the state tree — though wallets should treat amount=0 commitments as
67
+ * unspendable noise.)
68
+ */
69
+ export function createDummyNote() {
70
+ return {
71
+ amount: 0n,
72
+ label: 0n,
73
+ spendingPK: 0n,
74
+ viewingPKBlind: 0n,
75
+ nullifier: randomFieldElement(),
76
+ secret: randomFieldElement(),
77
+ };
78
+ }
79
+ // -----------------------------------------------------------------------------
80
+ // Commitment + nullifier helpers — v1 Railgun NPK recipe (matches note_spend.circom)
81
+ // -----------------------------------------------------------------------------
82
+ /**
83
+ * `NPK = Poseidon2(spendingPK, viewingPKBlind)`. The note-public-key the
84
+ * circuit's `NpkBuilder` template computes (note_spend.circom NpkBuilder).
85
+ */
86
+ export function npkOf(note) {
87
+ return poseidon2([note.spendingPK, note.viewingPKBlind]);
88
+ }
89
+ /**
90
+ * v1 UTXO commitment (audit C3): `commitment = Poseidon3(amount, NPK, secret)`,
91
+ * `NPK = Poseidon2(spendingPK, viewingPKBlind)`.
92
+ *
93
+ * This now matches `CommitmentHasher` in `note_spend.circom` EXACTLY (Poseidon3
94
+ * over `[amount, npk, secret]`). The prior v0 recipe
95
+ * `Poseidon3(amount, label, Poseidon2(nullifier, secret))` did NOT match the
96
+ * circuit and would have made every UTXO proof unverifiable.
97
+ *
98
+ * NOTE: this is the UTXO-pool recipe. The legacy single-value pool keeps its own
99
+ * (correct, deployed) recipe in `account.ts::computeCommitment` — do not unify
100
+ * them; they back two different pools.
101
+ */
102
+ export function commitmentOf(note) {
103
+ const npk = npkOf(note);
104
+ return poseidon3([note.amount, npk, note.secret]);
105
+ }
106
+ /**
107
+ * `nullifierHash = Poseidon1(nullifier)`. Imported from account.ts. Repeated
108
+ * here only for convenient note-centric API.
109
+ */
110
+ export function nullifierHashOf(note) {
111
+ return poseidon1([note.nullifier]);
112
+ }
113
+ // -----------------------------------------------------------------------------
114
+ // Split / merge helpers (pure value arithmetic, no circuit invocation)
115
+ // -----------------------------------------------------------------------------
116
+ export class NoteValueError extends Error {
117
+ }
118
+ /**
119
+ * Circuit range bound (audit F11): `note_spend.circom` range-checks every output
120
+ * amount and the withdrawn amount to 64 bits (`Num2Bits(64)`). Amounts at or
121
+ * above 2^64 would fail proof generation with an opaque error, or — if a caller
122
+ * skipped the SDK — risk field-wraparound creating value. We validate here so
123
+ * bad amounts are rejected up front with a clear message.
124
+ */
125
+ export const MAX_NOTE_AMOUNT = 1n << 64n;
126
+ /**
127
+ * Split a single note into two new notes that conserve the parent value and
128
+ * inherit its label. The two outputs are returned in the order
129
+ * `[primary, change]`; pass them straight into a 2-output spend.
130
+ */
131
+ export function splitNote(parent, primaryAmount) {
132
+ if (primaryAmount < 0n) {
133
+ throw new NoteValueError("splitNote: primaryAmount must be non-negative");
134
+ }
135
+ if (primaryAmount > parent.amount) {
136
+ throw new NoteValueError(`splitNote: primaryAmount ${primaryAmount} > parent.amount ${parent.amount}`);
137
+ }
138
+ // Carry the parent's NPK material so both children remain addressable to the
139
+ // same owner (change-back-to-self semantics). A real outbound transfer
140
+ // overrides the primary output's NPK with the recipient's at proof-build time.
141
+ const npk = { spendingPK: parent.spendingPK, viewingPKBlind: parent.viewingPKBlind };
142
+ return [
143
+ createNote(primaryAmount, parent.label, npk),
144
+ createNote(parent.amount - primaryAmount, parent.label, npk),
145
+ ];
146
+ }
147
+ /**
148
+ * Merge two notes (with matching label) into one. Returns the merged note.
149
+ * The caller must spend the two inputs and mint this one output via a spend.
150
+ */
151
+ export function mergeNotes(a, b) {
152
+ if (a.label !== b.label) {
153
+ throw new NoteValueError("mergeNotes: labels must match (cannot mix ASP attestations across pools)");
154
+ }
155
+ return createNote(a.amount + b.amount, a.label, {
156
+ spendingPK: a.spendingPK,
157
+ viewingPKBlind: a.viewingPKBlind,
158
+ });
159
+ }
160
+ /**
161
+ * Plan the inputs/outputs for paying `payAmount` out of an array of available
162
+ * notes. Returns the (≤2) input notes and (≤2) output notes for a v0 spend.
163
+ *
164
+ * Strategy:
165
+ * - Pick the smallest single note ≥ payAmount if possible (minimises change).
166
+ * - Otherwise merge the two largest notes — error if their sum is still
167
+ * insufficient (caller must perform multiple spends; defer to a future
168
+ * "PaymentPlanner" helper).
169
+ *
170
+ * This is a v0 heuristic; production wallets should use a coin-selection
171
+ * algorithm tuned for anonymity-set size (e.g., randomised LIFO).
172
+ */
173
+ export function planSpend(available, payAmount) {
174
+ if (payAmount < 0n) {
175
+ throw new NoteValueError("planSpend: payAmount must be non-negative");
176
+ }
177
+ if (payAmount >= MAX_NOTE_AMOUNT) {
178
+ throw new NoteValueError(`planSpend: payAmount ${payAmount} exceeds the 64-bit circuit range bound`);
179
+ }
180
+ if (available.length === 0) {
181
+ throw new NoteValueError("planSpend: no notes available");
182
+ }
183
+ for (const n of available) {
184
+ if (n.amount < 0n || n.amount >= MAX_NOTE_AMOUNT) {
185
+ throw new NoteValueError(`planSpend: note amount ${n.amount} out of the valid 64-bit range`);
186
+ }
187
+ }
188
+ const single = [...available]
189
+ .filter((n) => n.amount >= payAmount)
190
+ .sort((a, b) => (a.amount < b.amount ? -1 : a.amount > b.amount ? 1 : 0))[0];
191
+ if (single) {
192
+ const change = single.amount - payAmount;
193
+ // Phase 1A: returns [payAmount, change] not [change, 0]; downstream binding to outputCommitment[0]/[1]
194
+ return {
195
+ inputs: [single, createDummyNote()],
196
+ outputAmounts: [payAmount, change],
197
+ };
198
+ }
199
+ const sorted = [...available].sort((a, b) => a.amount > b.amount ? -1 : a.amount < b.amount ? 1 : 0);
200
+ if (sorted.length < 2 || sorted[0].amount + sorted[1].amount < payAmount) {
201
+ throw new NoteValueError(`planSpend: insufficient note balance for payment ${payAmount}`);
202
+ }
203
+ const change = sorted[0].amount + sorted[1].amount - payAmount;
204
+ // Phase 1A: returns [payAmount, change] not [change, 0]; downstream binding to outputCommitment[0]/[1]
205
+ return {
206
+ inputs: [sorted[0], sorted[1]],
207
+ outputAmounts: [payAmount, change],
208
+ };
209
+ }
210
+ /**
211
+ * Plan one dust-consolidation step (Phase 1C operational fix).
212
+ *
213
+ * Fragmented wallets leak: every spend that has to merge two inputs reveals
214
+ * (via the conservation law) that the spender held exactly those two note
215
+ * values, and a wallet forced into many small spends produces a recognisable
216
+ * on-chain cadence. Keeping the note count low ahead of time means real
217
+ * payments can take the single-input path, which is the quietest shape.
218
+ *
219
+ * Given the available notes and a target count, returns the inputs/outputs
220
+ * for ONE 2-in/1-out merge spend (the v0 circuit is fixed at 2×2, so a merge
221
+ * consumes two notes and mints one; the second output slot carries 0):
222
+ *
223
+ * - Only notes with amount > 0 count toward the threshold (zero-amount
224
+ * commitments are unspendable noise by convention — see createDummyNote).
225
+ * - Returns null when the spendable count is already ≤ threshold, or when
226
+ * no two spendable notes share a label (merging across labels would mix
227
+ * ASP attestations — same rule as mergeNotes).
228
+ * - Within the most-fragmented label group, the two SMALLEST notes merge
229
+ * first: dust is the priority, and small notes are the ones whose values
230
+ * are most identifiable in conservation-law analysis.
231
+ *
232
+ * Repeat-callable: apply the plan (spend the two inputs, keep the merged
233
+ * output), then call again with the updated set. Each round reduces the
234
+ * spendable count by exactly one, so looping until null terminates.
235
+ */
236
+ export function consolidateNotes(available, threshold = 3) {
237
+ if (threshold < 1) {
238
+ throw new NoteValueError("consolidateNotes: threshold must be >= 1");
239
+ }
240
+ const spendable = available.filter((n) => n.amount > 0n);
241
+ if (spendable.length <= threshold)
242
+ return null;
243
+ // Group by label; only same-label notes can merge (ASP attestation rule).
244
+ const byLabel = new Map();
245
+ for (const n of spendable) {
246
+ const group = byLabel.get(n.label);
247
+ if (group)
248
+ group.push(n);
249
+ else
250
+ byLabel.set(n.label, [n]);
251
+ }
252
+ // Pick the most-fragmented mergeable group; tie-break on smallest member
253
+ // so the choice is deterministic for a given note set.
254
+ let chosen = null;
255
+ for (const group of byLabel.values()) {
256
+ if (group.length < 2)
257
+ continue;
258
+ group.sort((a, b) => (a.amount < b.amount ? -1 : a.amount > b.amount ? 1 : 0));
259
+ if (!chosen ||
260
+ group.length > chosen.length ||
261
+ (group.length === chosen.length && group[0].amount < chosen[0].amount)) {
262
+ chosen = group;
263
+ }
264
+ }
265
+ if (!chosen)
266
+ return null; // every label holds a single note — nothing merges
267
+ const [a, b] = chosen;
268
+ return {
269
+ inputs: [a, b],
270
+ outputAmounts: [a.amount + b.amount, 0n],
271
+ };
272
+ }
273
+ // -----------------------------------------------------------------------------
274
+ // Serialization (off-chain wallet storage, not the on-chain encrypted form)
275
+ // -----------------------------------------------------------------------------
276
+ /**
277
+ * Serialize a note to a compact JSON-friendly form. BigInts are encoded as
278
+ * decimal strings; the round-trip is exact. Used for localStorage today; will
279
+ * back the facilitator's encrypted note cache later.
280
+ */
281
+ export function serializeNote(n) {
282
+ return JSON.stringify({
283
+ amount: n.amount.toString(),
284
+ label: n.label.toString(),
285
+ spendingPK: n.spendingPK.toString(),
286
+ viewingPKBlind: n.viewingPKBlind.toString(),
287
+ nullifier: n.nullifier.toString(),
288
+ secret: n.secret.toString(),
289
+ });
290
+ }
291
+ export function deserializeNote(s) {
292
+ const o = JSON.parse(s);
293
+ return {
294
+ amount: BigInt(o.amount),
295
+ label: BigInt(o.label),
296
+ // Tolerate pre-v1 serialized notes (no NPK fields) by defaulting to 0 —
297
+ // they predate the UTXO pool and never carried recipient NPK material.
298
+ spendingPK: o.spendingPK !== undefined ? BigInt(o.spendingPK) : 0n,
299
+ viewingPKBlind: o.viewingPKBlind !== undefined ? BigInt(o.viewingPKBlind) : 0n,
300
+ nullifier: BigInt(o.nullifier),
301
+ secret: BigInt(o.secret),
302
+ };
303
+ }
304
+ // -----------------------------------------------------------------------------
305
+ // Viewing-key cryptography
306
+ // -----------------------------------------------------------------------------
307
+ /**
308
+ * Generate a fresh X25519 viewing keypair. Private key is sampled from
309
+ * `randomBytes` (= crypto.getRandomValues underneath). Curve constraints
310
+ * (clamping) are handled by `x25519`'s scalar multiplication.
311
+ */
312
+ export function generateViewingKey() {
313
+ const privateKey = randomBytes(32);
314
+ const publicKey = x25519.getPublicKey(privateKey);
315
+ return { privateKey, publicKey };
316
+ }
317
+ /**
318
+ * Encrypt a note to a recipient's viewing public key.
319
+ *
320
+ * Returns the on-chain blob (see `EncryptedNote` layout above).
321
+ *
322
+ * Caveats:
323
+ * - The output is non-deterministic (ephemeral key + random nonce).
324
+ * - Includes a 2-byte view-tag to make recipient scanning O(N) trial-decrypt
325
+ * instead of O(N) full AEAD-decrypt.
326
+ * - Does NOT bind the ciphertext to any on-chain commitment. If you want
327
+ * AAD binding (preventing a malicious relayer from swapping ciphertexts
328
+ * between commitments) pass the commitment hash as `aad`.
329
+ */
330
+ export function encryptNote(note, recipientPublicKey, aad) {
331
+ if (recipientPublicKey.length !== 32) {
332
+ throw new Error("encryptNote: recipientPublicKey must be 32 bytes (X25519)");
333
+ }
334
+ // Ephemeral keypair
335
+ const ephPriv = randomBytes(32);
336
+ const ephPub = x25519.getPublicKey(ephPriv);
337
+ // Diffie-Hellman: shared = X25519(ephPriv, recipientPub)
338
+ const shared = x25519.getSharedSecret(ephPriv, recipientPublicKey);
339
+ // Symmetric key = keccak(shared) for domain separation from any other use
340
+ // of `shared` (e.g., view-tag).
341
+ const symKey = keccak_256(shared);
342
+ // View-tag = first 2 bytes of keccak(0x01 || shared)
343
+ // Domain-separated so a scanner can compute it without revealing it can also
344
+ // produce the symKey.
345
+ const viewTagInput = new Uint8Array(33);
346
+ viewTagInput[0] = 0x01;
347
+ viewTagInput.set(shared, 1);
348
+ const viewTag = keccak_256(viewTagInput).slice(0, 2);
349
+ // Nonce: 24 random bytes (XChaCha20)
350
+ const nonce = randomBytes(24);
351
+ const plaintext = new TextEncoder().encode(serializeNote(note));
352
+ const cipher = xchacha20poly1305(symKey, nonce, aad);
353
+ const ciphertext = cipher.encrypt(plaintext);
354
+ // Assemble: [ephPub (32)][viewTag (2)][nonce (24)][ciphertext]
355
+ const out = new Uint8Array(32 + 2 + 24 + ciphertext.length);
356
+ out.set(ephPub, 0);
357
+ out.set(viewTag, 32);
358
+ out.set(nonce, 34);
359
+ out.set(ciphertext, 58);
360
+ return out;
361
+ }
362
+ /**
363
+ * Attempt to decrypt a note using a viewing private key. Returns `null` on
364
+ * view-tag mismatch (fast path: 99.998% of foreign notes reject here) or on
365
+ * AEAD failure (cryptographically authenticated decryption).
366
+ *
367
+ * `aad` must match what the sender supplied at encryption time (commonly the
368
+ * commitment hash as bytes).
369
+ */
370
+ export function decryptNote(encrypted, viewingPrivateKey, aad) {
371
+ if (encrypted.length < 58 + 16)
372
+ return null; // need at least the AEAD tag
373
+ const ephPub = encrypted.slice(0, 32);
374
+ const viewTag = encrypted.slice(32, 34);
375
+ const nonce = encrypted.slice(34, 58);
376
+ const ciphertext = encrypted.slice(58);
377
+ const shared = x25519.getSharedSecret(viewingPrivateKey, ephPub);
378
+ // Fast reject via view-tag.
379
+ const viewTagInput = new Uint8Array(33);
380
+ viewTagInput[0] = 0x01;
381
+ viewTagInput.set(shared, 1);
382
+ const expectedTag = keccak_256(viewTagInput).slice(0, 2);
383
+ if (expectedTag[0] !== viewTag[0] || expectedTag[1] !== viewTag[1]) {
384
+ return null;
385
+ }
386
+ const symKey = keccak_256(shared);
387
+ const cipher = xchacha20poly1305(symKey, nonce, aad);
388
+ let plaintext;
389
+ try {
390
+ plaintext = cipher.decrypt(ciphertext);
391
+ }
392
+ catch {
393
+ // AEAD authentication failure — wrong key, corrupted blob, or wrong AAD.
394
+ return null;
395
+ }
396
+ return deserializeNote(new TextDecoder().decode(plaintext));
397
+ }
398
+ /**
399
+ * Scan an array of (commitment, encryptedNote) pairs and decrypt all notes
400
+ * addressed to the viewing key. Convenience helper for the wallet/facilitator.
401
+ *
402
+ * `aadFor` allows AAD binding to the commitment (default: no AAD).
403
+ */
404
+ export function scanNotes(entries, viewingPrivateKey, aadFor) {
405
+ const out = [];
406
+ for (const entry of entries) {
407
+ const aad = aadFor ? aadFor(entry.commitment) : undefined;
408
+ const note = decryptNote(entry.encrypted, viewingPrivateKey, aad);
409
+ if (note)
410
+ out.push({ commitment: entry.commitment, note });
411
+ }
412
+ return out;
413
+ }
414
+ // -----------------------------------------------------------------------------
415
+ // Phase 1B: AAD-bound transfer encryption
416
+ // -----------------------------------------------------------------------------
417
+ /**
418
+ * Encode a 256-bit field element as a 32-byte big-endian buffer. Mirrors
419
+ * Solidity's `abi.encode(uint256)` layout exactly (32 bytes, MSB first,
420
+ * zero-padded). Pulled in as a private helper so this module stays
421
+ * dependency-free of `account.ts`'s on-chain encoding utilities.
422
+ */
423
+ function uint256ToBE32(value) {
424
+ if (value < 0n) {
425
+ throw new Error(`uint256ToBE32: value must be non-negative, got ${value}`);
426
+ }
427
+ const out = new Uint8Array(32);
428
+ let v = value;
429
+ for (let i = 31; i >= 0 && v > 0n; i--) {
430
+ out[i] = Number(v & 0xffn);
431
+ v >>= 8n;
432
+ }
433
+ if (v > 0n) {
434
+ throw new Error("uint256ToBE32: value exceeds 256 bits");
435
+ }
436
+ return out;
437
+ }
438
+ /**
439
+ * Build the AAD that binds a ciphertext to its on-chain commitment.
440
+ *
441
+ * Must match the value the UTXOPool contract verifies in `transfer()`:
442
+ *
443
+ * aad = keccak256(abi.encode(outputCommitment)) (Solidity)
444
+ * = keccak256(uint256ToBE32(commitment)) (TypeScript, here)
445
+ *
446
+ * Returns the 32-byte keccak256 digest. Passing the bare commitment bytes
447
+ * instead would still bind to the commitment but would NOT match the contract
448
+ * — the on-chain hash is mandatory for the Phase 1B Fix 3 ciphertext-shuffle
449
+ * defense to engage.
450
+ */
451
+ export function commitmentAAD(commitment) {
452
+ return keccak_256(uint256ToBE32(commitment));
453
+ }
454
+ /**
455
+ * Convenience wrapper around `encryptNote` for the transfer use case.
456
+ * Auto-derives the AAD from the commitment so callers can't accidentally
457
+ * forget to bind. Equivalent to:
458
+ *
459
+ * encryptNote(note, recipientPub, commitmentAAD(commitment))
460
+ *
461
+ * Use this everywhere the SDK builds `CommitmentCiphertext` for `transfer()`.
462
+ */
463
+ export function encryptNoteForTransfer(note, recipientViewingPubKey, commitment) {
464
+ return encryptNote(note, recipientViewingPubKey, commitmentAAD(commitment));
465
+ }
466
+ /**
467
+ * Decrypt a transfer-bound ciphertext, requiring the AAD to match the given
468
+ * commitment. Returns `null` on:
469
+ *
470
+ * - view-tag mismatch (fast path — not this key's note),
471
+ * - AEAD authentication failure (wrong key, corrupted blob),
472
+ * - AAD mismatch (relayer attempted to shuffle ciphertexts between
473
+ * commitments — Phase 1B Fix 3).
474
+ *
475
+ * Callers should ALWAYS use this in the scanner, never the bare `decryptNote`,
476
+ * for `Transferred`-event ciphertexts. The bare form is reserved for legacy
477
+ * `NoteCommitted` payloads (Shipment A.1 v0) which predate AAD binding.
478
+ */
479
+ export function decryptNoteWithAAD(encrypted, viewingPrivateKey, expectedCommitment) {
480
+ return decryptNote(encrypted, viewingPrivateKey, commitmentAAD(expectedCommitment));
481
+ }
482
+ // -----------------------------------------------------------------------------
483
+ // On-chain ciphertext packing (UTXO transfer wire format)
484
+ // -----------------------------------------------------------------------------
485
+ //
486
+ // The on-chain `CommitmentCiphertext.ciphertext` field is a variable-length
487
+ // `bytes` (widened 2026-07-04 from Railgun's fixed `bytes32[4]`, which could not
488
+ // hold zBase's ~208-byte XChaCha20 envelope — see UTXOPool.sol). The contract
489
+ // does NOT parse these bytes; it only checks `aad`. So packing is an SDK↔scanner
490
+ // concern: we frame the raw `EncryptedNote` blob with a 1-byte version + a
491
+ // 4-byte big-endian length prefix, so the scanner can (a) reject a version it
492
+ // doesn't understand and (b) validate the payload arrived intact.
493
+ //
494
+ // packed = [version:1][len:4 BE][EncryptedNote blob:len]
495
+ //
496
+ // This is deliberately NOT Railgun's fixed 4-word slotting — that assumes a
497
+ // fixed-size plaintext zBase doesn't have. A length-prefixed blob round-trips
498
+ // any envelope size and is forward-compatible.
499
+ /** Wire-format version for packed transfer ciphertexts. */
500
+ export const CIPHERTEXT_WIRE_VERSION = 1;
501
+ /**
502
+ * Frame an EncryptedNote for the on-chain `CommitmentCiphertext.ciphertext`
503
+ * field. Returns a 0x-hex string ready to pass as Solidity `bytes`.
504
+ */
505
+ export function packCiphertext(encrypted) {
506
+ if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) {
507
+ throw new Error("packCiphertext: EncryptedNote must be a non-empty Uint8Array");
508
+ }
509
+ if (encrypted.length > 0xffffffff) {
510
+ throw new Error("packCiphertext: envelope too large");
511
+ }
512
+ const out = new Uint8Array(5 + encrypted.length);
513
+ out[0] = CIPHERTEXT_WIRE_VERSION;
514
+ const len = encrypted.length;
515
+ out[1] = (len >>> 24) & 0xff;
516
+ out[2] = (len >>> 16) & 0xff;
517
+ out[3] = (len >>> 8) & 0xff;
518
+ out[4] = len & 0xff;
519
+ out.set(encrypted, 5);
520
+ let hex = "0x";
521
+ for (const b of out)
522
+ hex += b.toString(16).padStart(2, "0");
523
+ return hex;
524
+ }
525
+ /**
526
+ * Inverse of packCiphertext: recover the EncryptedNote blob from the on-chain
527
+ * `bytes`. Returns null on malformed / wrong-version / length-mismatch input
528
+ * (a scanner should skip, not throw, on a payload it can't parse).
529
+ */
530
+ export function unpackCiphertext(packedHex) {
531
+ const hex = packedHex.startsWith("0x") ? packedHex.slice(2) : packedHex;
532
+ if (hex.length % 2 !== 0 || hex.length < 10)
533
+ return null; // need at least the 5-byte header
534
+ const bytes = new Uint8Array(hex.length / 2);
535
+ for (let i = 0; i < bytes.length; i++) {
536
+ const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
537
+ if (Number.isNaN(byte))
538
+ return null;
539
+ bytes[i] = byte;
540
+ }
541
+ if (bytes[0] !== CIPHERTEXT_WIRE_VERSION)
542
+ return null;
543
+ const len = (bytes[1] << 24) | (bytes[2] << 16) | (bytes[3] << 8) | bytes[4];
544
+ if (len <= 0 || 5 + len !== bytes.length)
545
+ return null; // length must match exactly
546
+ return bytes.slice(5);
547
+ }
package/dist/npk.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @zbase-protocol/core — Recipient NPK derivation (audit C3).
3
+ *
4
+ * The v1 UTXO commitment is `Poseidon3(amount, NPK, secret)` where
5
+ * `NPK = Poseidon2(spendingPK, viewingPKBlind)` (matches `note_spend.circom`
6
+ * NpkBuilder + CommitmentHasher — see `notes.ts`). Phase 1A pinned the *hash
7
+ * structure*; this module closes the remaining "auditor-scoped gap" noted in
8
+ * `notes.ts`: HOW `spendingPK` / `viewingPKBlind` are DERIVED from a recipient's
9
+ * published key material.
10
+ *
11
+ * ## Recipe (Railgun-style note-public-key)
12
+ *
13
+ * spendingPK = field(keccak("zBase/npk/spendingPK/v1" || recipientSpendPub))
14
+ * ephemeral = fresh X25519 keypair (per note)
15
+ * shared = X25519(ephemeralPriv, recipientViewingPub)
16
+ * viewingPKBlind = field(keccak("zBase/npk/viewingPKBlind/v1" || shared))
17
+ * NPK = Poseidon2(spendingPK, viewingPKBlind)
18
+ *
19
+ * Two properties this buys:
20
+ *
21
+ * 1. **Unlinkability.** `viewingPKBlind` is a fresh ECDH blind per note, so two
22
+ * notes to the same recipient produce different NPKs → different commitments.
23
+ * An on-chain observer cannot cluster a recipient's incoming notes.
24
+ * 2. **Recoverability.** The recipient publishes `ephemeralPublicKey` alongside
25
+ * the note (it already rides in the `encryptNote` blob — see `notes.ts`
26
+ * EncryptedNote layout, bytes [0..32)). With their viewing PRIVATE key they
27
+ * recompute the same `shared` → the same `viewingPKBlind` → the same NPK →
28
+ * the same commitment, confirming the note is theirs and that they can later
29
+ * prove `NPK = Poseidon2(spendingPK, viewingPKBlind)` in-circuit.
30
+ *
31
+ * `spendingPK` is long-lived (it identifies the recipient's spend authority and
32
+ * is constant across their notes); only `viewingPKBlind` is per-note. This is the
33
+ * standard Railgun split: the spending key authorizes movement, the blinded
34
+ * viewing key provides per-note privacy.
35
+ *
36
+ * ## What this is NOT
37
+ *
38
+ * The recipient's `spendingPublicKey` is a field element the recipient publishes
39
+ * (e.g., a Poseidon/Baby-Jubjub spend pubkey in a full wallet). zBase does not
40
+ * yet ship a spending-key HD scheme (only the X25519 viewing key — see
41
+ * `viewingKeyHD.ts`), so callers supply `recipientSpendingPubKey` explicitly.
42
+ * Until a spending-key tree lands, a recipient MAY reuse a stable field element
43
+ * (e.g., a hash of their viewing pubkey) as their spendingPK — at the cost of the
44
+ * spend-authority separation, NOT of the per-note unlinkability (which comes from
45
+ * the viewing blind). The derivation here is agnostic to that choice.
46
+ *
47
+ * ⚠️ AUDITOR NOTE: this is the SDK-side NPK derivation the circuit comment in
48
+ * `note_spend.circom` (NpkBuilder) and `notes.ts` defer to the external audit.
49
+ * The *circuit* only sees the resulting `NPK` field element; this module decides
50
+ * how it is built off-chain. It must be reviewed before the UTXO pool goes live.
51
+ */
52
+ /**
53
+ * Reduce arbitrary bytes to a BN254 scalar-field element via a domain-separated
54
+ * keccak, using DETERMINISTIC rejection sampling for a uniform result.
55
+ *
56
+ * audit-sweep-2026-06-17 (was a latent MED): the previous version did a naive
57
+ * `keccak(...) % SNARK_SCALAR_FIELD` and a comment claimed the bias was
58
+ * "< 2^-253". That was wrong by ~250 bits: r ≈ 2^253.6, so 2^256 / r = 5 with a
59
+ * large remainder — the lowest ~29% of the field is over-represented, giving a
60
+ * statistical distance of ~2^-4.2 (~5.4%), not 2^-253. For `spendingPK` (public
61
+ * input) the bias is cosmetic, but `viewingPKBlind` is derived from a SECRET
62
+ * (the X25519 shared secret), so a biased reduction on a secret-derived witness
63
+ * is a real (if bounded) uniformity weakness. We now reject any digest in the
64
+ * "extra" top band [LIMIT, 2^256) and re-hash with an incrementing counter byte,
65
+ * so every accepted value is uniform over [0, r). The counter keeps it fully
66
+ * deterministic — the recipient recomputes the identical element. Expected
67
+ * iterations ≈ 1.25; the loop is bounded defensively.
68
+ */
69
+ export declare function bytesToField(domain: string, bytes: Uint8Array): bigint;
70
+ /** The NPK inputs plus the ephemeral pubkey the recipient needs to recover them. */
71
+ export interface DerivedNPK {
72
+ /** Long-lived recipient spend authority, as a field element. */
73
+ spendingPK: bigint;
74
+ /** Per-note blinded viewing pubkey, as a field element. */
75
+ viewingPKBlind: bigint;
76
+ /**
77
+ * Sender's per-note ephemeral X25519 public key (32 bytes). MUST be published
78
+ * with the note so the recipient can recompute `viewingPKBlind`. In a transfer
79
+ * this is the same ephemeral key `encryptNote` embeds; callers MAY pass that
80
+ * key in via `ephemeralPrivateKey` to avoid generating two.
81
+ */
82
+ ephemeralPublicKey: Uint8Array;
83
+ }
84
+ export interface DeriveRecipientNPKParams {
85
+ /**
86
+ * Recipient's published viewing PUBLIC key (32-byte X25519). Same key used by
87
+ * `encryptNote` to encrypt the note payload.
88
+ */
89
+ recipientViewingPubKey: Uint8Array;
90
+ /**
91
+ * Recipient's long-lived spending public key, as a field element. The caller
92
+ * obtains this from the recipient's published address material. See the module
93
+ * doc for the "reuse a stable field element" interim option.
94
+ */
95
+ recipientSpendingPubKey: bigint;
96
+ /**
97
+ * Optional: reuse a specific ephemeral private key (e.g., the one `encryptNote`
98
+ * will use) so the note carries ONE ephemeral pubkey. If omitted, a fresh
99
+ * 32-byte key is generated. MUST be 32 bytes.
100
+ */
101
+ ephemeralPrivateKey?: Uint8Array;
102
+ }
103
+ /**
104
+ * Derive a recipient's NPK inputs for a fresh outbound note.
105
+ *
106
+ * Deterministic given (recipientViewingPubKey, recipientSpendingPubKey,
107
+ * ephemeralPrivateKey): pass a fixed ephemeral key to get reproducible output
108
+ * (used by tests + by the witness fixture builder). With no ephemeral key it is
109
+ * non-deterministic (fresh per-note blind), which is the production default.
110
+ */
111
+ export declare function deriveRecipientNPK(params: DeriveRecipientNPKParams): DerivedNPK;
112
+ /**
113
+ * Recipient-side recovery of `viewingPKBlind` from the published ephemeral
114
+ * pubkey + the recipient's viewing PRIVATE key. Recomputes the same ECDH shared
115
+ * secret the sender used, so the recipient can reconstruct the NPK (and hence the
116
+ * commitment) and confirm the note is addressed to them.
117
+ *
118
+ * shared = X25519(viewingPriv, ephemeralPub) // == sender's X25519(ephPriv, viewingPub)
119
+ * viewingPKBlind = field(keccak(DST || shared))
120
+ */
121
+ export declare function recoverViewingPKBlind(viewingPrivateKey: Uint8Array, ephemeralPublicKey: Uint8Array): bigint;