diff --git a/artifacts/program_methods/aggregator_circuit.bin b/artifacts/program_methods/aggregator_circuit.bin index f4de2438..3d1512fd 100644 Binary files a/artifacts/program_methods/aggregator_circuit.bin and b/artifacts/program_methods/aggregator_circuit.bin differ diff --git a/artifacts/program_methods/aggregator_circuit_strict.bin b/artifacts/program_methods/aggregator_circuit_strict.bin index 01355931..3b962055 100644 Binary files a/artifacts/program_methods/aggregator_circuit_strict.bin and b/artifacts/program_methods/aggregator_circuit_strict.bin differ diff --git a/artifacts/test_program_methods/ppe_aggregation.bin b/artifacts/test_program_methods/ppe_aggregation.bin index 1e0c5a20..2fd55a6e 100644 Binary files a/artifacts/test_program_methods/ppe_aggregation.bin and b/artifacts/test_program_methods/ppe_aggregation.bin differ diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs index 2724eb25..858df477 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs @@ -962,13 +962,15 @@ mod tests { let mut env_builder = ExecutorEnv::builder(); env_builder.write(&PRIVACY_PRESERVING_CIRCUIT_ID).unwrap(); - env_builder.write(&(proofs.len() as u32)).unwrap(); - // Write journals first, then add assumptions — ordering matters for the guest. + // Outputs are written once as a word-native `Vec<&PrivacyPreservingCircuitOutput>` + // (matching `aggregator_circuit`'s `AggregatorCircuitInput`) instead of N raw + // `Vec` journal buffers — see the ppe_aggregation guest for why. + let outputs: Vec<&PrivacyPreservingCircuitOutput> = + proofs.iter().map(|(o, _)| o).collect(); + env_builder.write(&outputs).unwrap(); + let journals: Vec> = proofs.iter().map(|(o, _)| o.to_bytes()).collect(); - for journal in &journals { - env_builder.write(journal).unwrap(); - } for ((_, proof), journal) in proofs.iter().zip(&journals) { let inner: InnerReceipt = borsh::from_slice(&proof.0).unwrap(); env_builder.add_assumption(Receipt::new(inner, journal.clone())); @@ -1035,14 +1037,19 @@ mod tests { } env_builder.write(&PRIVACY_PRESERVING_CIRCUIT_ID).unwrap(); - env_builder - .write(&u32::try_from(fixtures.len()).expect("fixture count fits in u32")) - .unwrap(); - // Journals must be written before assumptions (guest reads them in order). - for f in &fixtures { - env_builder.write(&f.output_bytes).unwrap(); - } + // Outputs are written once as a word-native `Vec` + // (matching `aggregator_circuit`'s `AggregatorCircuitInput`) instead of N raw + // `Vec` journal buffers — see the ppe_aggregation guest for why. + let outputs: Vec = fixtures + .iter() + .map(|f| { + let words: &[u32] = bytemuck::cast_slice(&f.output_bytes); + risc0_zkvm::serde::from_slice(words).expect("fixture output_bytes invalid") + }) + .collect(); + env_builder.write(&outputs).unwrap(); + for f in &fixtures { let inner: InnerReceipt = borsh::from_slice(&f.proof_bytes) .expect("fixture proof_bytes is not a valid InnerReceipt"); @@ -1058,10 +1065,14 @@ mod tests { let proof_size = borsh::to_vec(&prove_info.receipt.inner).unwrap().len(); eprintln!( - "[lee::analytics] ppe_aggregation n={} proving_ms={} proof_size_bytes={}", + "[lee::analytics] ppe_aggregation n={} proving_ms={} proof_size_bytes={} \ + segments={} total_cycles={} user_cycles={}", fixtures.len(), proving_ms, proof_size, + prove_info.stats.segments, + prove_info.stats.total_cycles, + prove_info.stats.user_cycles, ); prove_info diff --git a/program_methods/guest/src/bin/aggregator_circuit/main.rs b/program_methods/guest/src/bin/aggregator_circuit/main.rs index d9261b23..2a99b792 100644 --- a/program_methods/guest/src/bin/aggregator_circuit/main.rs +++ b/program_methods/guest/src/bin/aggregator_circuit/main.rs @@ -7,7 +7,7 @@ //! The full `PrivacyPreservingCircuitOutput` for each transaction is committed to the //! journal so observers can perform state-dependent checks independently. -use std::{collections::HashSet, convert::Infallible}; +use std::convert::Infallible; use lee_core::{AggregatorCircuitInput, AggregatorCircuitOutput, Commitment, Nullifier, account::AccountId}; use risc0_zkvm::{guest::env, serde::to_vec}; @@ -27,36 +27,41 @@ fn main() { .unwrap_or_else(|_: Infallible| unreachable!("Infallible error is never constructed")); } - let mut seen_nullifiers: HashSet = HashSet::new(); + // Linear-scan dedup: batches are small (n is bounded), so a `Vec` + `contains` check + // avoids the per-element hashing cost of `HashSet` in the zkVM. + let mut seen_nullifiers: Vec = Vec::new(); for output in &circuit_outputs { for (nullifier, _) in &output.new_nullifiers { assert!( - seen_nullifiers.insert(*nullifier), + !seen_nullifiers.contains(nullifier), "Duplicate nullifier across transactions in batch" ); + seen_nullifiers.push(*nullifier); } } - let mut seen_commitments: HashSet = HashSet::new(); + let mut seen_commitments: Vec = Vec::new(); for output in &circuit_outputs { for commitment in &output.new_commitments { assert!( - seen_commitments.insert(commitment.clone()), + !seen_commitments.contains(commitment), "Duplicate commitment across transactions in batch" ); + seen_commitments.push(commitment.clone()); } } - let mut seen_updated_account_ids: HashSet = HashSet::new(); + let mut seen_updated_account_ids: Vec = Vec::new(); for output in &circuit_outputs { for (pre_state, post_state) in output.public_pre_states.iter().zip(output.public_post_states.iter()) { if pre_state.account != *post_state { assert!( - seen_updated_account_ids.insert(pre_state.account_id), + !seen_updated_account_ids.contains(&pre_state.account_id), "Public account updated by multiple transactions in batch" ); + seen_updated_account_ids.push(pre_state.account_id); } } } diff --git a/program_methods/guest/src/bin/aggregator_circuit_strict/main.rs b/program_methods/guest/src/bin/aggregator_circuit_strict/main.rs index 3232336a..2b6ff9e3 100644 --- a/program_methods/guest/src/bin/aggregator_circuit_strict/main.rs +++ b/program_methods/guest/src/bin/aggregator_circuit_strict/main.rs @@ -3,7 +3,7 @@ //! Extends the core aggregator circuit with one additional check proven inside RISC0: //! - Each transaction's validity window contains the provided `block_id` and `timestamp`. -use std::{collections::HashSet, convert::Infallible}; +use std::convert::Infallible; use lee_core::{ AggregatorCircuitInput, AggregatorCircuitOutput, Commitment, Nullifier, account::AccountId, @@ -25,23 +25,27 @@ fn main() { .unwrap_or_else(|_: Infallible| unreachable!("Infallible error is never constructed")); } - let mut seen_nullifiers: HashSet = HashSet::new(); + // Linear-scan dedup: batches are small (n is bounded), so a `Vec` + `contains` check + // avoids the per-element hashing cost of `HashSet` in the zkVM. + let mut seen_nullifiers: Vec = Vec::new(); for output in &circuit_outputs { for (nullifier, _) in &output.new_nullifiers { assert!( - seen_nullifiers.insert(*nullifier), + !seen_nullifiers.contains(nullifier), "Duplicate nullifier across transactions in batch" ); + seen_nullifiers.push(*nullifier); } } - let mut seen_commitments: HashSet = HashSet::new(); + let mut seen_commitments: Vec = Vec::new(); for output in &circuit_outputs { for commitment in &output.new_commitments { assert!( - seen_commitments.insert(commitment.clone()), + !seen_commitments.contains(commitment), "Duplicate commitment across transactions in batch" ); + seen_commitments.push(commitment.clone()); } } @@ -56,16 +60,17 @@ fn main() { ); } - let mut seen_updated_account_ids: HashSet = HashSet::new(); + let mut seen_updated_account_ids: Vec = Vec::new(); for output in &circuit_outputs { for (pre_state, post_state) in output.public_pre_states.iter().zip(output.public_post_states.iter()) { if pre_state.account != *post_state { assert!( - seen_updated_account_ids.insert(pre_state.account_id), + !seen_updated_account_ids.contains(&pre_state.account_id), "Public account updated by multiple transactions in batch" ); + seen_updated_account_ids.push(pre_state.account_id); } } } diff --git a/test_program_methods/guest/src/bin/ppe_aggregation.rs b/test_program_methods/guest/src/bin/ppe_aggregation.rs index 23632b58..5d8e887c 100644 --- a/test_program_methods/guest/src/bin/ppe_aggregation.rs +++ b/test_program_methods/guest/src/bin/ppe_aggregation.rs @@ -1,12 +1,11 @@ use lee_core::PrivacyPreservingCircuitOutput; -use risc0_zkvm::guest::env; +use risc0_zkvm::{guest::env, serde::to_vec}; /// Aggregation circuit for N privacy-preserving execution proofs. /// /// The host writes: /// 1. The PPE circuit image ID (`[u32; 8]`) -/// 2. The count N (`u32`) -/// 3. N journal byte-buffers (each produced by `PrivacyPreservingCircuitOutput::to_bytes()`) +/// 2. `Vec` — the N outputs to verify and re-commit /// /// It also loads each PPE receipt as an assumption before running this guest. /// `env::verify` checks each assumption cryptographically; if any proof is @@ -14,25 +13,24 @@ use risc0_zkvm::guest::env; /// /// Journal: `Vec` — the verifier recovers all /// circuit outputs from the single aggregated proof. +/// +/// Outputs are read once as a word-native `Vec<...>` and re-serialized per-output via +/// `to_vec()` for `env::verify`, mirroring `aggregator_circuit`. This replaced reading +/// each journal as a raw `env::read::>()`: risc0's default serde deserializes +/// `Vec` one byte at a time (each unpacked from a word), which costs more guest +/// cycles than the word-native path. `to_vec(output)` and `output.to_bytes()` produce +/// identical bytes, so the assumption journal digest is unchanged. fn main() { // The host passes the PPE circuit image ID so the guest stays independent // of the host-only `lee` crate. let ppe_image_id: [u32; 8] = env::read(); - let count: u32 = env::read(); + let outputs: Vec = env::read(); - let mut outputs = Vec::with_capacity(count as usize); - - for _ in 0..count { - let journal: Vec = env::read(); - - env::verify(ppe_image_id, &journal) + for output in &outputs { + let output_words = + to_vec(output).expect("PrivacyPreservingCircuitOutput serialization should not fail"); + env::verify(ppe_image_id, &output_words) .expect("PPE_aggregation: a PPE proof failed verification"); - - let word_slice: &[u32] = bytemuck::cast_slice(&journal); - let output: PrivacyPreservingCircuitOutput = - risc0_zkvm::serde::from_slice(word_slice) - .expect("PPE_aggregation: failed to deserialise circuit output"); - outputs.push(output); } env::commit(&outputs);