Skip to main content

curvy_core/
witness.rs

1//! Witness builders - native Rust port of `witnessFromNotes.ts` /
2//! `pendingNotesCommitmentInputs.ts`. They produce the **flat snarkjs input
3//! objects** (circom field-declaration order) by composing the ported Domain-B
4//! primitives + the [`crate::imt`] tree. Pure assembly: no randomness, no IO.
5//!
6//! Inclusion proofs are **supplied** (Mode A, the lean/stateless path): each is
7//! `(leaf_index, siblings)`, matching `SuppliedInclusionProofs`.
8
9use ark_ff::AdditiveGroup;
10use num_bigint::BigUint;
11use serde::Serialize;
12
13use crate::cipher::encrypt_amount_token;
14use crate::eddsa::{ScalarSignatureError, ScalarSigningKey, Signature, sign_hex};
15use crate::field::{Bn254Fr, Fr, fr_to_biguint, fr_to_dec};
16use crate::hash_utils::sha256_bigint;
17use crate::imt::Imt;
18use crate::note as commitments;
19use crate::poseidon::poseidon;
20
21/// A note as the witness builders see it. `shared_secret`/`ephemeral_key` are
22/// BabyJubjub field coordinates (`< r`); they convert to raw `BigUint` for the
23/// cipher (where `< r` values pack identically).
24#[derive(Clone)]
25pub struct Note {
26    pub amount: Fr,
27    pub token: Fr,
28    pub owner_pub: (Fr, Fr),
29    pub shared_secret: Fr,
30    pub ephemeral_key: (Fr, Fr),
31    pub view_tag: Fr,
32}
33
34/// Explicit note-owner construction for profiles that already know the checked
35/// BabyJubJub owner point and shared secret. Neither value is derived from the
36/// other.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub struct KnownOwner {
39    pub owner: crate::babyjubjub::BabyJubPoint,
40    pub shared_secret: Bn254Fr,
41}
42
43impl KnownOwner {
44    pub fn new(owner: crate::babyjubjub::BabyJubPoint, shared_secret: Bn254Fr) -> Self {
45        Self {
46            owner,
47            shared_secret,
48        }
49    }
50
51    pub fn note(self, amount: Fr, token: Fr, ephemeral_key: (Fr, Fr), view_tag: Fr) -> Note {
52        Note {
53            amount,
54            token,
55            owner_pub: self.owner.as_tuple(),
56            shared_secret: self.shared_secret.into_inner(),
57            ephemeral_key,
58            view_tag,
59        }
60    }
61}
62
63impl Note {
64    pub fn owner_hash(&self) -> Fr {
65        commitments::owner_hash(self.owner_pub, self.shared_secret)
66    }
67    pub fn id(&self) -> Fr {
68        commitments::note_id(self.owner_hash(), self.amount, self.token)
69    }
70    pub fn nullifier(&self) -> Fr {
71        commitments::nullifier(self.shared_secret, self.owner_pub)
72    }
73    /// `flatNote`: `[owner.x, owner.y, sharedSecret, amount, token]`.
74    fn flat(&self) -> Vec<String> {
75        vec![
76            fr_to_dec(&self.owner_pub.0),
77            fr_to_dec(&self.owner_pub.1),
78            fr_to_dec(&self.shared_secret),
79            fr_to_dec(&self.amount),
80            fr_to_dec(&self.token),
81        ]
82    }
83    /// `(encryptedAmount, encryptedToken)` for this note's amount/token.
84    fn encrypted(&self) -> (Fr, Fr) {
85        let out = encrypt_amount_token(
86            self.amount,
87            self.token,
88            &fr_to_biguint(&self.shared_secret),
89            (
90                &fr_to_biguint(&self.ephemeral_key.0),
91                &fr_to_biguint(&self.ephemeral_key.1),
92            ),
93        );
94        (out.encrypted_amount, out.encrypted_token)
95    }
96    /// `flatEncrypted`: `[encAmount, encToken, eph.x, eph.y, viewTag]`.
97    fn flat_encrypted(&self) -> Vec<String> {
98        let (ea, et) = self.encrypted();
99        vec![
100            fr_to_dec(&ea),
101            fr_to_dec(&et),
102            fr_to_dec(&self.ephemeral_key.0),
103            fr_to_dec(&self.ephemeral_key.1),
104            fr_to_dec(&self.view_tag),
105        ]
106    }
107}
108
109/// A supplied inclusion proof: `(leaf_index, siblings)`.
110pub struct Proof {
111    pub leaf_index: u64,
112    pub siblings: Vec<Fr>,
113}
114
115impl Proof {
116    /// `flatInclusion`: `[leafIndex, ...siblings]`.
117    fn flat(&self) -> Vec<String> {
118        let mut out = vec![self.leaf_index.to_string()];
119        out.extend(self.siblings.iter().map(fr_to_dec));
120        out
121    }
122}
123
124fn flat_signature(r8: (Fr, Fr), s: &BigUint) -> [String; 3] {
125    [s.to_string(), fr_to_dec(&r8.0), fr_to_dec(&r8.1)]
126}
127
128/// Source of a BabyJubJub public key and Curvy-compatible signature. Witness
129/// builders consume both from the same object so a caller cannot accidentally
130/// pair a signature with a different public key.
131pub trait NoteSigner {
132    fn public_key(&self) -> (Fr, Fr);
133    fn sign(&self, message: Fr) -> Result<Signature, ScalarSignatureError>;
134}
135
136/// Seed-backed signer for Curvy accounts that use BLAKE/prune key derivation.
137pub struct SeedNoteSigner<'a> {
138    private_key_hex: &'a str,
139    public_key: (Fr, Fr),
140}
141
142impl<'a> SeedNoteSigner<'a> {
143    /// Derive the public point using the seed-backed BLAKE/prune profile.
144    pub fn new(private_key_hex: &'a str) -> Self {
145        Self {
146            private_key_hex,
147            public_key: crate::eddsa::pub_from_private_key_hex(private_key_hex),
148        }
149    }
150
151    /// Constructor for established callers that serialize a public point
152    /// separately. New integrations should prefer [`Self::new`], which derives it.
153    fn from_parts(private_key_hex: &'a str, public_key: (Fr, Fr)) -> Self {
154        Self {
155            private_key_hex,
156            public_key,
157        }
158    }
159}
160
161impl NoteSigner for SeedNoteSigner<'_> {
162    fn public_key(&self) -> (Fr, Fr) {
163        self.public_key
164    }
165
166    fn sign(&self, message: Fr) -> Result<Signature, ScalarSignatureError> {
167        Ok(sign_hex(&fr_to_biguint(&message), self.private_key_hex))
168    }
169}
170
171impl NoteSigner for ScalarSigningKey {
172    fn public_key(&self) -> (Fr, Fr) {
173        self.verifying_key().as_tuple()
174    }
175
176    fn sign(&self, message: Fr) -> Result<Signature, ScalarSignatureError> {
177        Ok(self
178            .sign_curvy_v1(Bn254Fr::from_fr(message))?
179            .to_signature())
180    }
181}
182
183// ── Withdrawal ──────────────────────────────────────────────────────────────
184
185#[derive(Serialize, PartialEq, Eq, Debug)]
186pub struct WithdrawalWitness {
187    #[serde(rename = "inputNotes")]
188    pub input_notes: Vec<Vec<String>>,
189    #[serde(rename = "publicKey")]
190    pub public_key: [String; 2],
191    #[serde(rename = "inputNoteInclusionProofs")]
192    pub input_note_inclusion_proofs: Vec<Vec<String>>,
193    pub signature: [String; 3],
194    #[serde(rename = "notesRoot")]
195    pub notes_root: String,
196    #[serde(rename = "destinationAddress")]
197    pub destination_address: String,
198    #[serde(rename = "tokenId")]
199    pub token_id: String,
200}
201
202/// `generateWithdrawalCircuitInputsFromNotes` + `flattenWithdrawalCircuitInputs`.
203/// Signing message: `Poseidon([...nullifiers, destinationAddress, withdrawnAmount, tokenId])`.
204pub fn build_withdrawal(
205    notes: &[Note],
206    owner_key_hex: &str,
207    public_key: (Fr, Fr),
208    proofs: &[Proof],
209    notes_root: Fr,
210    destination_address: Fr,
211    token_id: Fr,
212) -> WithdrawalWitness {
213    let signer = SeedNoteSigner::from_parts(owner_key_hex, public_key);
214    build_withdrawal_with_signer(
215        notes,
216        &signer,
217        proofs,
218        notes_root,
219        destination_address,
220        token_id,
221    )
222    .expect("seed-backed signing is infallible")
223}
224
225/// Build a withdrawal witness using either a seed-backed or scalar-backed signer.
226/// The witness public key is always obtained from the signer.
227pub fn build_withdrawal_with_signer(
228    notes: &[Note],
229    signer: &impl NoteSigner,
230    proofs: &[Proof],
231    notes_root: Fr,
232    destination_address: Fr,
233    token_id: Fr,
234) -> Result<WithdrawalWitness, ScalarSignatureError> {
235    let total: Fr = notes.iter().fold(Fr::ZERO, |a, n| a + n.amount);
236    let mut msg: Vec<Fr> = notes.iter().map(|n| n.nullifier()).collect();
237    msg.push(destination_address);
238    msg.push(total);
239    msg.push(token_id);
240    let sig = signer.sign(poseidon(&msg))?;
241    let public_key = signer.public_key();
242
243    Ok(WithdrawalWitness {
244        input_notes: notes.iter().map(|n| n.flat()).collect(),
245        public_key: [fr_to_dec(&public_key.0), fr_to_dec(&public_key.1)],
246        input_note_inclusion_proofs: proofs.iter().map(|p| p.flat()).collect(),
247        signature: flat_signature(sig.r8, &sig.s),
248        notes_root: fr_to_dec(&notes_root),
249        destination_address: fr_to_dec(&destination_address),
250        token_id: fr_to_dec(&token_id),
251    })
252}
253
254// ── Aggregation ─────────────────────────────────────────────────────────────
255
256#[derive(Serialize, PartialEq, Eq, Debug)]
257pub struct AggregationWitness {
258    #[serde(rename = "inputNotes")]
259    pub input_notes: Vec<Vec<String>>,
260    #[serde(rename = "inputNoteInclusionProofs")]
261    pub input_note_inclusion_proofs: Vec<Vec<String>>,
262    #[serde(rename = "outputNotes")]
263    pub output_notes: Vec<Vec<String>>,
264    #[serde(rename = "publicKey")]
265    pub public_key: [String; 2],
266    pub signature: [String; 3],
267    #[serde(rename = "feeNote")]
268    pub fee_note: Vec<String>,
269    #[serde(rename = "encryptedNoteData")]
270    pub encrypted_note_data: Vec<Vec<String>>,
271    #[serde(rename = "notesRoot")]
272    pub notes_root: String,
273    #[serde(rename = "protocolFeePerThousand")]
274    pub protocol_fee_per_thousand: String,
275    #[serde(rename = "gasFee")]
276    pub gas_fee: String,
277    #[serde(rename = "feeNotePublicKey")]
278    pub fee_note_public_key: [String; 2],
279}
280
281/// `buildAggregationWitnessBundle` (deterministic tail) + `flattenAggregationCircuitInputs`.
282/// `input_notes`/`output_notes` are already resolved + padded (no randomness here).
283/// Signing message: `Poseidon([ Poseidon(outputNoteIds), Poseidon(encNoteData flat amount/token) ])`.
284#[allow(clippy::too_many_arguments)]
285pub fn build_aggregation(
286    input_notes: &[Note],
287    input_proofs: &[Proof],
288    output_notes: &[Note],
289    fee_note: &Note,
290    owner_key_hex: &str,
291    public_key: (Fr, Fr),
292    notes_root: Fr,
293    protocol_fee_per_thousand: Fr,
294    gas_fee: Fr,
295    fee_note_public_key: (Fr, Fr),
296) -> AggregationWitness {
297    let signer = SeedNoteSigner::from_parts(owner_key_hex, public_key);
298    build_aggregation_with_signer(
299        input_notes,
300        input_proofs,
301        output_notes,
302        fee_note,
303        &signer,
304        notes_root,
305        protocol_fee_per_thousand,
306        gas_fee,
307        fee_note_public_key,
308    )
309    .expect("seed-backed signing is infallible")
310}
311
312/// Build an aggregation witness using either a seed-backed or scalar-backed
313/// signer. The witness public key is always obtained from the signer.
314#[allow(clippy::too_many_arguments)]
315pub fn build_aggregation_with_signer(
316    input_notes: &[Note],
317    input_proofs: &[Proof],
318    output_notes: &[Note],
319    fee_note: &Note,
320    signer: &impl NoteSigner,
321    notes_root: Fr,
322    protocol_fee_per_thousand: Fr,
323    gas_fee: Fr,
324    fee_note_public_key: (Fr, Fr),
325) -> Result<AggregationWitness, ScalarSignatureError> {
326    let enc_notes: Vec<Note> = output_notes
327        .iter()
328        .chain(std::iter::once(fee_note))
329        .cloned()
330        .collect();
331    let encrypted: Vec<(Fr, Fr)> = enc_notes.iter().map(|n| n.encrypted()).collect();
332
333    let output_note_hash = poseidon(&output_notes.iter().map(|n| n.id()).collect::<Vec<_>>());
334    let mut enc_flat: Vec<Fr> = Vec::with_capacity(encrypted.len() * 2);
335    for (ea, et) in &encrypted {
336        enc_flat.push(*ea);
337        enc_flat.push(*et);
338    }
339    let encrypted_note_data_hash = poseidon(&enc_flat);
340    let signing_hash = poseidon(&[output_note_hash, encrypted_note_data_hash]);
341    let sig = signer.sign(signing_hash)?;
342    let public_key = signer.public_key();
343
344    Ok(AggregationWitness {
345        input_notes: input_notes.iter().map(|n| n.flat()).collect(),
346        input_note_inclusion_proofs: input_proofs.iter().map(|p| p.flat()).collect(),
347        output_notes: output_notes.iter().map(|n| n.flat()).collect(),
348        public_key: [fr_to_dec(&public_key.0), fr_to_dec(&public_key.1)],
349        signature: flat_signature(sig.r8, &sig.s),
350        fee_note: fee_note.flat(),
351        encrypted_note_data: enc_notes.iter().map(|n| n.flat_encrypted()).collect(),
352        notes_root: fr_to_dec(&notes_root),
353        protocol_fee_per_thousand: fr_to_dec(&protocol_fee_per_thousand),
354        gas_fee: fr_to_dec(&gas_fee),
355        fee_note_public_key: [
356            fr_to_dec(&fee_note_public_key.0),
357            fr_to_dec(&fee_note_public_key.1),
358        ],
359    })
360}
361
362// ── Pending-notes commitment ────────────────────────────────────────────────
363
364#[derive(Serialize, PartialEq, Eq, Debug)]
365pub struct PendingCommitmentWitness {
366    #[serde(rename = "currentNoteIndex")]
367    pub current_note_index: String,
368    #[serde(rename = "inputHash")]
369    pub input_hash: String,
370    #[serde(rename = "currentNotesRoot")]
371    pub current_notes_root: String,
372    #[serde(rename = "pendingNoteIds")]
373    pub pending_note_ids: Vec<String>,
374    pub siblings: Vec<Vec<String>>,
375    #[serde(rename = "newNotesRoot")]
376    pub new_notes_root: String,
377}
378
379/// `generatePendingNotesCommitmentCircuitInputs`. Mutates a copy of the tree: each
380/// non-zero pending id is inserted (zero ids are skip slots with zero siblings).
381/// `inputHash = sha256BigInt([...paddedIds, currentRoot, newRoot, currentIndex, newIndex])`.
382pub fn build_pending_commitment(
383    tree: &Imt,
384    tree_depth: usize,
385    batch_size: usize,
386    pending_note_ids: &[Fr],
387) -> PendingCommitmentWitness {
388    assert!(
389        pending_note_ids.len() <= batch_size,
390        "pending ids exceed batch size"
391    );
392    let current_notes_root = tree.root();
393    let current_note_index = tree.leaf_count() as u64;
394
395    let mut padded = pending_note_ids.to_vec();
396    padded.resize(batch_size, Fr::ZERO);
397
398    let mut work = tree.clone();
399    let mut siblings: Vec<Vec<Fr>> = Vec::with_capacity(batch_size);
400    for &id in &padded {
401        if id == Fr::ZERO {
402            siblings.push(vec![Fr::ZERO; tree_depth]);
403            continue;
404        }
405        work.insert(id);
406        let idx = work.leaf_count() - 1;
407        siblings.push(work.create_proof(idx).siblings);
408    }
409
410    let new_notes_root = work.root();
411    let new_note_index = work.leaf_count() as u64;
412
413    let mut hash_inputs: Vec<BigUint> = padded.iter().map(fr_to_biguint).collect();
414    hash_inputs.push(fr_to_biguint(&current_notes_root));
415    hash_inputs.push(fr_to_biguint(&new_notes_root));
416    hash_inputs.push(BigUint::from(current_note_index));
417    hash_inputs.push(BigUint::from(new_note_index));
418    let input_hash = sha256_bigint(&hash_inputs);
419
420    PendingCommitmentWitness {
421        current_note_index: current_note_index.to_string(),
422        input_hash: input_hash.to_string(),
423        current_notes_root: fr_to_dec(&current_notes_root),
424        pending_note_ids: padded.iter().map(fr_to_dec).collect(),
425        siblings: siblings
426            .iter()
427            .map(|row| row.iter().map(fr_to_dec).collect())
428            .collect(),
429        new_notes_root: fr_to_dec(&new_notes_root),
430    }
431}