@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.
@@ -0,0 +1,305 @@
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
+ /**
27
+ * Plaintext note. Lives off-chain (in the wallet, in the facilitator's
28
+ * per-user cache, or as the decrypted payload of a `NoteCommitted` log).
29
+ *
30
+ * v1 RECIPE (audit C3): the on-chain commitment recipe was migrated to match
31
+ * the frozen `note_spend.circom`:
32
+ *
33
+ * NPK = Poseidon2(spendingPK, viewingPKBlind)
34
+ * commitment = Poseidon3(amount, NPK, secret)
35
+ *
36
+ * The previous v0 recipe `Poseidon3(amount, label, Poseidon2(nullifier,secret))`
37
+ * did NOT match the circuit, so any UTXO proof the SDK built would have failed
38
+ * verification. `label` is no longer part of the commitment (it now binds
39
+ * privately through the NPK + the ASP-membership witness), but is retained on
40
+ * the note because the circuit still proves ASP membership of `label`.
41
+ *
42
+ * ⚠️ AUDITOR-SCOPED GAP: this aligns the commitment HASH STRUCTURE with the
43
+ * circuit. How `spendingPK` / `viewingPKBlind` are DERIVED from the recipient's
44
+ * viewing key (the Railgun NPK key-derivation, and exactly how the ASP label is
45
+ * folded into that derivation off-chain) is NOT yet implemented in the SDK and
46
+ * must be designed + reviewed in the external circuit audit before the UTXO pool
47
+ * goes live. Until then these are caller-supplied fields.
48
+ */
49
+ export interface Note {
50
+ /** USDC atomic units (6 decimal places). 0 marks a dummy / zero-note. */
51
+ amount: bigint;
52
+ /** ASP label inherited from the originating deposit. Proven for ASP
53
+ * membership; NOT part of the commitment in v1. */
54
+ label: bigint;
55
+ /** Recipient spending public key (field element) — NPK input. */
56
+ spendingPK: bigint;
57
+ /** Per-note blinded recipient viewing public key (field element) — NPK input. */
58
+ viewingPKBlind: bigint;
59
+ /** Per-note nullifier (random field element). */
60
+ nullifier: bigint;
61
+ /** Per-note secret (random field element). */
62
+ secret: bigint;
63
+ }
64
+ /**
65
+ * Viewing keypair. The private key gives read access to all notes addressed to
66
+ * this owner; it cannot move funds (which require the per-note nullifier+secret).
67
+ */
68
+ export interface ViewingKeyPair {
69
+ privateKey: Uint8Array;
70
+ publicKey: Uint8Array;
71
+ }
72
+ /**
73
+ * Encrypted note as it would appear on-chain inside a `NoteCommitted` log:
74
+ *
75
+ * bytes layout:
76
+ * [0..32) ephemeral X25519 public key
77
+ * [32..34) 2-byte view-tag (firstTwoBytes(keccak(shared)))
78
+ * [34..58) XChaCha20 nonce (24 bytes)
79
+ * [58..) AEAD ciphertext (plaintext_len + 16-byte Poly1305 tag)
80
+ *
81
+ * Total per-note overhead ≈ 58 + ciphertext_len. The plaintext is ~120-150
82
+ * bytes after JSON serialization, putting per-note calldata cost at ~3K gas
83
+ * (well under the ~250K Groth16 verification cost).
84
+ */
85
+ export type EncryptedNote = Uint8Array;
86
+ /**
87
+ * Create a fresh note with random nullifier + secret.
88
+ *
89
+ * `amount` + `label` come from a deposit or a parent note being split. The NPK
90
+ * inputs (`spendingPK`, `viewingPKBlind`) identify the RECIPIENT and bind the
91
+ * label privately — they default to 0 here for the self-spend / scaffold case,
92
+ * but a real transfer MUST pass the recipient's derived NPK inputs.
93
+ *
94
+ * ⚠️ Deriving spendingPK/viewingPKBlind from a recipient's viewing key is the
95
+ * auditor-scoped gap (see the Note doc). Until that lands, callers either pass
96
+ * 0 (change-back-to-self placeholder) or supply explicit values.
97
+ */
98
+ export declare function createNote(amount: bigint, label: bigint, npk?: {
99
+ spendingPK: bigint;
100
+ viewingPKBlind: bigint;
101
+ }): Note;
102
+ /**
103
+ * Create a zero-value dummy note. Used as a placeholder input/output to fill
104
+ * the fixed N=2/M=2 slots of the v0 spend circuit when the spender does not
105
+ * actually need 2 inputs or 2 outputs.
106
+ *
107
+ * A dummy carries amount=0 and label=0; the circuit's `inIsDummy[i]` flag
108
+ * must be set to 1 for input dummies. (Output dummies are valid as-is because
109
+ * a 0-amount output commitment hashes deterministically and inserts harmlessly
110
+ * into the state tree — though wallets should treat amount=0 commitments as
111
+ * unspendable noise.)
112
+ */
113
+ export declare function createDummyNote(): Note;
114
+ /**
115
+ * `NPK = Poseidon2(spendingPK, viewingPKBlind)`. The note-public-key the
116
+ * circuit's `NpkBuilder` template computes (note_spend.circom NpkBuilder).
117
+ */
118
+ export declare function npkOf(note: Pick<Note, "spendingPK" | "viewingPKBlind">): bigint;
119
+ /**
120
+ * v1 UTXO commitment (audit C3): `commitment = Poseidon3(amount, NPK, secret)`,
121
+ * `NPK = Poseidon2(spendingPK, viewingPKBlind)`.
122
+ *
123
+ * This now matches `CommitmentHasher` in `note_spend.circom` EXACTLY (Poseidon3
124
+ * over `[amount, npk, secret]`). The prior v0 recipe
125
+ * `Poseidon3(amount, label, Poseidon2(nullifier, secret))` did NOT match the
126
+ * circuit and would have made every UTXO proof unverifiable.
127
+ *
128
+ * NOTE: this is the UTXO-pool recipe. The legacy single-value pool keeps its own
129
+ * (correct, deployed) recipe in `account.ts::computeCommitment` — do not unify
130
+ * them; they back two different pools.
131
+ */
132
+ export declare function commitmentOf(note: Note): bigint;
133
+ /**
134
+ * `nullifierHash = Poseidon1(nullifier)`. Imported from account.ts. Repeated
135
+ * here only for convenient note-centric API.
136
+ */
137
+ export declare function nullifierHashOf(note: Note): bigint;
138
+ export declare class NoteValueError extends Error {
139
+ }
140
+ /**
141
+ * Circuit range bound (audit F11): `note_spend.circom` range-checks every output
142
+ * amount and the withdrawn amount to 64 bits (`Num2Bits(64)`). Amounts at or
143
+ * above 2^64 would fail proof generation with an opaque error, or — if a caller
144
+ * skipped the SDK — risk field-wraparound creating value. We validate here so
145
+ * bad amounts are rejected up front with a clear message.
146
+ */
147
+ export declare const MAX_NOTE_AMOUNT: bigint;
148
+ /**
149
+ * Split a single note into two new notes that conserve the parent value and
150
+ * inherit its label. The two outputs are returned in the order
151
+ * `[primary, change]`; pass them straight into a 2-output spend.
152
+ */
153
+ export declare function splitNote(parent: Note, primaryAmount: bigint): [Note, Note];
154
+ /**
155
+ * Merge two notes (with matching label) into one. Returns the merged note.
156
+ * The caller must spend the two inputs and mint this one output via a spend.
157
+ */
158
+ export declare function mergeNotes(a: Note, b: Note): Note;
159
+ /**
160
+ * Plan the inputs/outputs for paying `payAmount` out of an array of available
161
+ * notes. Returns the (≤2) input notes and (≤2) output notes for a v0 spend.
162
+ *
163
+ * Strategy:
164
+ * - Pick the smallest single note ≥ payAmount if possible (minimises change).
165
+ * - Otherwise merge the two largest notes — error if their sum is still
166
+ * insufficient (caller must perform multiple spends; defer to a future
167
+ * "PaymentPlanner" helper).
168
+ *
169
+ * This is a v0 heuristic; production wallets should use a coin-selection
170
+ * algorithm tuned for anonymity-set size (e.g., randomised LIFO).
171
+ */
172
+ export declare function planSpend(available: readonly Note[], payAmount: bigint): {
173
+ inputs: [Note, Note];
174
+ outputAmounts: [bigint, bigint];
175
+ };
176
+ /**
177
+ * Plan one dust-consolidation step (Phase 1C operational fix).
178
+ *
179
+ * Fragmented wallets leak: every spend that has to merge two inputs reveals
180
+ * (via the conservation law) that the spender held exactly those two note
181
+ * values, and a wallet forced into many small spends produces a recognisable
182
+ * on-chain cadence. Keeping the note count low ahead of time means real
183
+ * payments can take the single-input path, which is the quietest shape.
184
+ *
185
+ * Given the available notes and a target count, returns the inputs/outputs
186
+ * for ONE 2-in/1-out merge spend (the v0 circuit is fixed at 2×2, so a merge
187
+ * consumes two notes and mints one; the second output slot carries 0):
188
+ *
189
+ * - Only notes with amount > 0 count toward the threshold (zero-amount
190
+ * commitments are unspendable noise by convention — see createDummyNote).
191
+ * - Returns null when the spendable count is already ≤ threshold, or when
192
+ * no two spendable notes share a label (merging across labels would mix
193
+ * ASP attestations — same rule as mergeNotes).
194
+ * - Within the most-fragmented label group, the two SMALLEST notes merge
195
+ * first: dust is the priority, and small notes are the ones whose values
196
+ * are most identifiable in conservation-law analysis.
197
+ *
198
+ * Repeat-callable: apply the plan (spend the two inputs, keep the merged
199
+ * output), then call again with the updated set. Each round reduces the
200
+ * spendable count by exactly one, so looping until null terminates.
201
+ */
202
+ export declare function consolidateNotes(available: readonly Note[], threshold?: number): {
203
+ inputs: [Note, Note];
204
+ outputAmounts: [bigint, bigint];
205
+ } | null;
206
+ /**
207
+ * Serialize a note to a compact JSON-friendly form. BigInts are encoded as
208
+ * decimal strings; the round-trip is exact. Used for localStorage today; will
209
+ * back the facilitator's encrypted note cache later.
210
+ */
211
+ export declare function serializeNote(n: Note): string;
212
+ export declare function deserializeNote(s: string): Note;
213
+ /**
214
+ * Generate a fresh X25519 viewing keypair. Private key is sampled from
215
+ * `randomBytes` (= crypto.getRandomValues underneath). Curve constraints
216
+ * (clamping) are handled by `x25519`'s scalar multiplication.
217
+ */
218
+ export declare function generateViewingKey(): ViewingKeyPair;
219
+ /**
220
+ * Encrypt a note to a recipient's viewing public key.
221
+ *
222
+ * Returns the on-chain blob (see `EncryptedNote` layout above).
223
+ *
224
+ * Caveats:
225
+ * - The output is non-deterministic (ephemeral key + random nonce).
226
+ * - Includes a 2-byte view-tag to make recipient scanning O(N) trial-decrypt
227
+ * instead of O(N) full AEAD-decrypt.
228
+ * - Does NOT bind the ciphertext to any on-chain commitment. If you want
229
+ * AAD binding (preventing a malicious relayer from swapping ciphertexts
230
+ * between commitments) pass the commitment hash as `aad`.
231
+ */
232
+ export declare function encryptNote(note: Note, recipientPublicKey: Uint8Array, aad?: Uint8Array): EncryptedNote;
233
+ /**
234
+ * Attempt to decrypt a note using a viewing private key. Returns `null` on
235
+ * view-tag mismatch (fast path: 99.998% of foreign notes reject here) or on
236
+ * AEAD failure (cryptographically authenticated decryption).
237
+ *
238
+ * `aad` must match what the sender supplied at encryption time (commonly the
239
+ * commitment hash as bytes).
240
+ */
241
+ export declare function decryptNote(encrypted: EncryptedNote, viewingPrivateKey: Uint8Array, aad?: Uint8Array): Note | null;
242
+ /**
243
+ * Scan an array of (commitment, encryptedNote) pairs and decrypt all notes
244
+ * addressed to the viewing key. Convenience helper for the wallet/facilitator.
245
+ *
246
+ * `aadFor` allows AAD binding to the commitment (default: no AAD).
247
+ */
248
+ export declare function scanNotes(entries: ReadonlyArray<{
249
+ commitment: bigint;
250
+ encrypted: EncryptedNote;
251
+ }>, viewingPrivateKey: Uint8Array, aadFor?: (commitment: bigint) => Uint8Array | undefined): Array<{
252
+ commitment: bigint;
253
+ note: Note;
254
+ }>;
255
+ /**
256
+ * Build the AAD that binds a ciphertext to its on-chain commitment.
257
+ *
258
+ * Must match the value the UTXOPool contract verifies in `transfer()`:
259
+ *
260
+ * aad = keccak256(abi.encode(outputCommitment)) (Solidity)
261
+ * = keccak256(uint256ToBE32(commitment)) (TypeScript, here)
262
+ *
263
+ * Returns the 32-byte keccak256 digest. Passing the bare commitment bytes
264
+ * instead would still bind to the commitment but would NOT match the contract
265
+ * — the on-chain hash is mandatory for the Phase 1B Fix 3 ciphertext-shuffle
266
+ * defense to engage.
267
+ */
268
+ export declare function commitmentAAD(commitment: bigint): Uint8Array;
269
+ /**
270
+ * Convenience wrapper around `encryptNote` for the transfer use case.
271
+ * Auto-derives the AAD from the commitment so callers can't accidentally
272
+ * forget to bind. Equivalent to:
273
+ *
274
+ * encryptNote(note, recipientPub, commitmentAAD(commitment))
275
+ *
276
+ * Use this everywhere the SDK builds `CommitmentCiphertext` for `transfer()`.
277
+ */
278
+ export declare function encryptNoteForTransfer(note: Note, recipientViewingPubKey: Uint8Array, commitment: bigint): EncryptedNote;
279
+ /**
280
+ * Decrypt a transfer-bound ciphertext, requiring the AAD to match the given
281
+ * commitment. Returns `null` on:
282
+ *
283
+ * - view-tag mismatch (fast path — not this key's note),
284
+ * - AEAD authentication failure (wrong key, corrupted blob),
285
+ * - AAD mismatch (relayer attempted to shuffle ciphertexts between
286
+ * commitments — Phase 1B Fix 3).
287
+ *
288
+ * Callers should ALWAYS use this in the scanner, never the bare `decryptNote`,
289
+ * for `Transferred`-event ciphertexts. The bare form is reserved for legacy
290
+ * `NoteCommitted` payloads (Shipment A.1 v0) which predate AAD binding.
291
+ */
292
+ export declare function decryptNoteWithAAD(encrypted: EncryptedNote, viewingPrivateKey: Uint8Array, expectedCommitment: bigint): Note | null;
293
+ /** Wire-format version for packed transfer ciphertexts. */
294
+ export declare const CIPHERTEXT_WIRE_VERSION: 1;
295
+ /**
296
+ * Frame an EncryptedNote for the on-chain `CommitmentCiphertext.ciphertext`
297
+ * field. Returns a 0x-hex string ready to pass as Solidity `bytes`.
298
+ */
299
+ export declare function packCiphertext(encrypted: EncryptedNote): `0x${string}`;
300
+ /**
301
+ * Inverse of packCiphertext: recover the EncryptedNote blob from the on-chain
302
+ * `bytes`. Returns null on malformed / wrong-version / length-mismatch input
303
+ * (a scanner should skip, not throw, on a payload it can't parse).
304
+ */
305
+ export declare function unpackCiphertext(packedHex: string): EncryptedNote | null;