@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/LICENSE +201 -0
- package/NOTICE +20 -0
- package/README.md +118 -0
- package/dist/account.d.ts +79 -0
- package/dist/account.js +140 -0
- package/dist/experimental.d.ts +27 -0
- package/dist/experimental.js +23 -0
- package/dist/facilitatorClient.d.ts +171 -0
- package/dist/facilitatorClient.js +146 -0
- package/dist/forwardingNotes.d.ts +103 -0
- package/dist/forwardingNotes.js +149 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +23 -0
- package/dist/merkle.d.ts +48 -0
- package/dist/merkle.js +84 -0
- package/dist/noteScanner.d.ts +206 -0
- package/dist/noteScanner.js +422 -0
- package/dist/notes.d.ts +305 -0
- package/dist/notes.js +547 -0
- package/dist/npk.d.ts +121 -0
- package/dist/npk.js +166 -0
- package/dist/proofs.d.ts +66 -0
- package/dist/proofs.js +45 -0
- package/dist/stealth.d.ts +136 -0
- package/dist/stealth.js +387 -0
- package/dist/swapPlan.d.ts +52 -0
- package/dist/swapPlan.js +104 -0
- package/dist/types.d.ts +172 -0
- package/dist/types.js +4 -0
- package/dist/viewingKeyHD.d.ts +108 -0
- package/dist/viewingKeyHD.js +176 -0
- package/package.json +71 -0
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;
|