From 2620c42ab4e75a09d322ad1be0c45506e05ebcc5 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Fri, 5 Jun 2026 16:52:30 -0400 Subject: [PATCH] initialize tests --- Cargo.lock | 15 ++ Cargo.toml | 1 + bench_ppe_aggregation.sh | 50 ++++ .../privacy_preserving_transaction/circuit.rs | 228 ++++++++++++++++++ test_program_methods/Cargo.toml | 3 + test_program_methods/guest/Cargo.toml | 1 + .../guest/src/bin/ppe_aggregation.rs | 39 +++ test_program_methods/src/fixtures.rs | 38 +++ test_program_methods/src/lib.rs | 3 + tools/ppe_test_data_gen/Cargo.toml | 25 ++ tools/ppe_test_data_gen/src/main.rs | 193 +++++++++++++++ 11 files changed, 596 insertions(+) create mode 100755 bench_ppe_aggregation.sh create mode 100644 test_program_methods/guest/src/bin/ppe_aggregation.rs create mode 100644 test_program_methods/src/fixtures.rs create mode 100644 tools/ppe_test_data_gen/Cargo.toml create mode 100644 tools/ppe_test_data_gen/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index e0c8ea2c..9d526733 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7454,6 +7454,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppe_test_data_gen" +version = "0.1.0" +dependencies = [ + "anyhow", + "authenticated_transfer_core", + "borsh", + "clap", + "lee", + "lee_core", + "risc0-zkvm", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -9851,6 +9864,7 @@ dependencies = [ name = "test_program_methods" version = "0.1.0" dependencies = [ + "borsh", "risc0-build", ] @@ -9859,6 +9873,7 @@ name = "test_programs" version = "0.1.0" dependencies = [ "authenticated_transfer_core", + "bytemuck", "clock_core", "faucet_core", "lee_core", diff --git a/Cargo.toml b/Cargo.toml index 290c3540..23205c4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ members = [ "tools/cycle_bench", "tools/crypto_primitives_bench", "tools/integration_bench", + "tools/ppe_test_data_gen", ] [workspace.dependencies] diff --git a/bench_ppe_aggregation.sh b/bench_ppe_aggregation.sh new file mode 100755 index 00000000..590b6440 --- /dev/null +++ b/bench_ppe_aggregation.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Runs the PPE aggregation test across a range of fixture counts and prints a results table. +# +# Usage: +# ./bench_ppe_aggregation.sh +# +# Environment: +# PPE_FIXTURES - path to the fixture file (default: ppe_fixtures.bin) +# COUNTS - space-separated list of counts to test (default: powers of 2, 1..256) +# +# Example: +# PPE_FIXTURES=/path/to/ppe_fixtures.bin COUNTS="4 8 16" ./bench_ppe_aggregation.sh + +set -euo pipefail + +FIXTURES="$(realpath "${PPE_FIXTURES:-ppe_fixtures.bin}")" +COUNTS="${COUNTS:-1 2 4 6 8 10 12 14 16}" + +if [ ! -f "$FIXTURES" ]; then + echo "ERROR: fixture file '$FIXTURES' not found." + echo "Generate it first:" + echo " RISC0_DEV_MODE=1 cargo run --release -p ppe_test_data_gen -- --output $FIXTURES" + exit 1 +fi + +printf "\n%-6s %14s %20s\n" "n" "proving_ms" "proof_size_bytes" +printf "%-6s %14s %20s\n" "------" "--------------" "--------------------" + +for count in $COUNTS; do + line=$( + PPE_FIXTURES="$FIXTURES" \ + PPE_FIXTURES_COUNT="$count" \ + cargo test -p lee aggregate_ppe_proofs_from_fixtures -- --nocapture 2>&1 \ + | grep -v "^test_programs:" \ + | grep "\[lee::analytics\] ppe_aggregation" || true + ) + + if [ -z "$line" ]; then + printf "%-6s %14s %20s\n" "$count" "skipped" "-" + continue + fi + + n=$(echo "$line" | grep -o 'n=[0-9]*' | cut -d= -f2) + proving_ms=$(echo "$line" | grep -o 'proving_ms=[0-9]*' | cut -d= -f2) + proof_size=$(echo "$line" | grep -o 'proof_size_bytes=[0-9]*'| cut -d= -f2) + + printf "%-6s %14s %20s\n" "$n" "$proving_ms" "$proof_size" +done + +printf "\n" diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs index cebef4cf..ee968819 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs @@ -838,6 +838,234 @@ mod tests { assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); } + /// Prove N PPE transactions and aggregate all their proofs into one succinct receipt. + /// + /// Uses the `ppe_aggregation` guest (in `test_program_methods`). The host + /// loads each PPE receipt as an assumption and passes the journals to the + /// guest, which calls `env::verify` for each. The resulting receipt proves + /// "all N PPE executions were valid" in one succinct proof. + /// + /// This test uses N=3 to demonstrate the generalised aggregation. + #[test] + fn aggregate_n_ppe_proofs() { + use lee_core::account::Nonce; + use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; + use test_program_methods::{PPE_AGGREGATION_ELF, PPE_AGGREGATION_ID}; + + let program = Program::authenticated_transfer_program(); + + // ── Proof 0: public sender → private recipient ──────────────────────────── + let keys_0 = test_private_account_keys_1(); + let ssk_0 = SharedSecretKey::encapsulate_deterministic(&keys_0.vpk(), &[0_u8; 32], 0).0; + let (output_0, proof_0) = execute_and_prove( + vec![ + AccountWithMetadata::new( + Account { program_owner: program.id(), balance: 100, ..Account::default() }, + true, + AccountId::new([0x01; 32]), + ), + AccountWithMetadata::new( + Account::default(), + false, + AccountId::for_regular_private_account(&keys_0.npk(), 0), + ), + ], + Program::serialize_instruction( + authenticated_transfer_core::Instruction::Transfer { amount: 10 }, + ).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk: keys_0.npk(), + ssk: ssk_0, + identifier: 0, + }, + ], + &program.clone().into(), + ).expect("proof 0 should succeed"); + + // ── Proof 1: public sender → private recipient ──────────────────────────── + let keys_1 = test_private_account_keys_2(); + let ssk_1 = SharedSecretKey::encapsulate_deterministic(&keys_1.vpk(), &[0_u8; 32], 0).0; + let (output_1, proof_1) = execute_and_prove( + vec![ + AccountWithMetadata::new( + Account { program_owner: program.id(), balance: 200, ..Account::default() }, + true, + AccountId::new([0x02; 32]), + ), + AccountWithMetadata::new( + Account::default(), + false, + AccountId::for_regular_private_account(&keys_1.npk(), 0), + ), + ], + Program::serialize_instruction( + authenticated_transfer_core::Instruction::Transfer { amount: 20 }, + ).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk: keys_1.npk(), + ssk: ssk_1, + identifier: 0, + }, + ], + &program.clone().into(), + ).expect("proof 1 should succeed"); + + // ── Proof 2: fully private transfer ────────────────────────────────────── + let sender_keys_2 = test_private_account_keys_1(); + let recipient_keys_2 = test_private_account_keys_2(); + let sender_2_id = AccountId::for_regular_private_account(&sender_keys_2.npk(), 0); + let sender_2_account = + Account { program_owner: program.id(), balance: 50, nonce: Nonce(1), ..Account::default() }; + let sender_2_commitment = Commitment::new(&sender_2_id, &sender_2_account); + let mut cs = CommitmentSet::with_capacity(1); + cs.extend(std::slice::from_ref(&sender_2_commitment)); + // sender is output index 0, recipient is output index 1 + let ssk_2_sender = + SharedSecretKey::encapsulate_deterministic(&sender_keys_2.vpk(), &[0_u8; 32], 0).0; + let ssk_2_recipient = + SharedSecretKey::encapsulate_deterministic(&recipient_keys_2.vpk(), &[0_u8; 32], 1).0; + let (output_2, proof_2) = execute_and_prove( + vec![ + AccountWithMetadata::new(sender_2_account, true, sender_2_id), + AccountWithMetadata::new( + Account::default(), + false, + AccountId::for_regular_private_account(&recipient_keys_2.npk(), 1), + ), + ], + Program::serialize_instruction( + authenticated_transfer_core::Instruction::Transfer { amount: 30 }, + ).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: ssk_2_sender, + nsk: sender_keys_2.nsk, + membership_proof: cs.get_proof_for(&sender_2_commitment).unwrap(), + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys_2.npk(), + ssk: ssk_2_recipient, + identifier: 1, + }, + ], + &program.into(), + ).expect("proof 2 should succeed"); + + // ── Aggregate all three ─────────────────────────────────────────────────── + let proofs: Vec<(PrivacyPreservingCircuitOutput, Proof)> = + vec![(output_0, proof_0), (output_1, proof_1), (output_2, proof_2)]; + + 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. + 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())); + } + + let env = env_builder.build().unwrap(); + let prove_info = default_prover() + .prove_with_opts(env, PPE_AGGREGATION_ELF, &ProverOpts::succinct()) + .expect("aggregation proving should succeed"); + + prove_info + .receipt + .verify(PPE_AGGREGATION_ID) + .expect("aggregated proof must verify"); + + let recovered: Vec = + prove_info.receipt.journal.decode().unwrap(); + assert_eq!(recovered.len(), proofs.len()); + for (i, (expected_output, _)) in proofs.iter().enumerate() { + assert_eq!(&recovered[i], expected_output, "output {i} mismatch"); + } + } + + /// Aggregate pre-generated PPE proofs loaded from disk. + /// + /// This test isolates the aggregation circuit from individual transaction proving: + /// it loads fixtures produced by `ppe_test_data_gen`, reconstructs each receipt, + /// and runs only the `PPE_AGGREGATION_ELF` circuit — no `execute_and_prove` call. + /// + /// Skips gracefully when the fixture file is absent so CI is not broken. + /// To run: generate fixtures first, then point the test at them: + /// + /// ```sh + /// RISC0_DEV_MODE=1 cargo run --release -p ppe_test_data_gen -- --output ppe_fixtures.bin + /// RISC0_DEV_MODE=1 cargo test -p lee aggregate_ppe_proofs_from_fixtures + /// ``` + /// + /// Override the path via the `PPE_FIXTURES` env var (default: `ppe_fixtures.bin`). + #[test] + fn aggregate_ppe_proofs_from_fixtures() { + use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; + use test_program_methods::{PPE_AGGREGATION_ELF, PPE_AGGREGATION_ID, PpeFixture}; + + let path = + std::env::var("PPE_FIXTURES").unwrap_or_else(|_| "ppe_fixtures.bin".to_owned()); + let mut fixtures = PpeFixture::load_bundle(&path); + + if fixtures.is_empty() { + return; // file absent — load_bundle already printed a skip notice + } + + if let Ok(count_str) = std::env::var("PPE_FIXTURES_COUNT") { + let count: usize = count_str.parse().expect("PPE_FIXTURES_COUNT must be a number"); + fixtures.truncate(count); + } + + let mut env_builder = ExecutorEnv::builder(); + 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(); + } + for f in &fixtures { + let inner: InnerReceipt = borsh::from_slice(&f.proof_bytes) + .expect("fixture proof_bytes is not a valid InnerReceipt"); + env_builder.add_assumption(Receipt::new(inner, f.output_bytes.clone())); + } + + let env = env_builder.build().unwrap(); + let t0 = std::time::Instant::now(); + let prove_info = default_prover() + .prove_with_opts(env, PPE_AGGREGATION_ELF, &ProverOpts::succinct()) + .expect("aggregation proving should succeed"); + let proving_ms = t0.elapsed().as_millis(); + + let proof_size = borsh::to_vec(&prove_info.receipt.inner).unwrap().len(); + eprintln!( + "[lee::analytics] ppe_aggregation n={} proving_ms={} proof_size_bytes={}", + fixtures.len(), + proving_ms, + proof_size, + ); + + prove_info + .receipt + .verify(PPE_AGGREGATION_ID) + .expect("aggregated proof must verify"); + + let recovered: Vec = + prove_info.receipt.journal.decode().unwrap(); + assert_eq!(recovered.len(), fixtures.len(), "recovered output count mismatch"); + } + #[test] fn private_pda_update_identifier_mismatch_fails() { let program = Program::pda_spend_proxy(); diff --git a/test_program_methods/Cargo.toml b/test_program_methods/Cargo.toml index 9b4934e2..c048a80f 100644 --- a/test_program_methods/Cargo.toml +++ b/test_program_methods/Cargo.toml @@ -7,6 +7,9 @@ license = { workspace = true } [lints] workspace = true +[dependencies] +borsh.workspace = true + [build-dependencies] risc0-build.workspace = true diff --git a/test_program_methods/guest/Cargo.toml b/test_program_methods/guest/Cargo.toml index f08b520b..f308b786 100644 --- a/test_program_methods/guest/Cargo.toml +++ b/test_program_methods/guest/Cargo.toml @@ -14,4 +14,5 @@ clock_core.workspace = true faucet_core.workspace = true risc0-zkvm.workspace = true +bytemuck.workspace = true serde = { workspace = true, default-features = false } diff --git a/test_program_methods/guest/src/bin/ppe_aggregation.rs b/test_program_methods/guest/src/bin/ppe_aggregation.rs new file mode 100644 index 00000000..23632b58 --- /dev/null +++ b/test_program_methods/guest/src/bin/ppe_aggregation.rs @@ -0,0 +1,39 @@ +use lee_core::PrivacyPreservingCircuitOutput; +use risc0_zkvm::guest::env; + +/// 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()`) +/// +/// It also loads each PPE receipt as an assumption before running this guest. +/// `env::verify` checks each assumption cryptographically; if any proof is +/// invalid the guest panics and no aggregation receipt is produced. +/// +/// Journal: `Vec` — the verifier recovers all +/// circuit outputs from the single aggregated proof. +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 mut outputs = Vec::with_capacity(count as usize); + + for _ in 0..count { + let journal: Vec = env::read(); + + env::verify(ppe_image_id, &journal) + .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); +} diff --git a/test_program_methods/src/fixtures.rs b/test_program_methods/src/fixtures.rs new file mode 100644 index 00000000..aad5cb10 --- /dev/null +++ b/test_program_methods/src/fixtures.rs @@ -0,0 +1,38 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +/// A single pre-generated PPE proof fixture. +/// +/// Produced by `ppe_test_data_gen` and consumed by the aggregation test so that +/// individual transaction proof generation is fully decoupled from the aggregation step. +/// +/// Load a bundle with [`PpeFixture::load_bundle`]. +#[derive(BorshSerialize, BorshDeserialize)] +pub struct PpeFixture { + /// Human-readable label identifying the scenario. + pub label: String, + /// `PrivacyPreservingCircuitOutput` encoded via `to_bytes()` (risc0 serde / u32 word slice). + /// This is the journal that was committed by the PPE circuit. + pub output_bytes: Vec, + /// Borsh-encoded `InnerReceipt` (from `Proof::into_inner()`). + pub proof_bytes: Vec, +} + +impl PpeFixture { + /// Loads a Borsh-encoded `Vec` from `path`. + /// + /// Returns an empty `Vec` (and prints a skip notice) when the file does not exist, + /// so that test suites skip gracefully when fixtures have not been generated yet. + /// Any other I/O error or deserialisation failure panics with a diagnostic message. + pub fn load_bundle(path: &str) -> Vec { + if !std::path::Path::new(path).exists() { + eprintln!( + "[test_program_methods] PPE fixture file '{path}' not found — skipping. \ + Run `RISC0_DEV_MODE=1 cargo run --release -p ppe_test_data_gen` to generate it." + ); + return Vec::new(); + } + let bytes = std::fs::read(path) + .unwrap_or_else(|e| panic!("failed to read PPE fixture file '{path}': {e}")); + borsh::from_slice(&bytes).expect("PPE fixture bundle failed Borsh deserialisation") + } +} diff --git a/test_program_methods/src/lib.rs b/test_program_methods/src/lib.rs index 1bdb3085..1607ca28 100644 --- a/test_program_methods/src/lib.rs +++ b/test_program_methods/src/lib.rs @@ -1 +1,4 @@ +pub mod fixtures; +pub use fixtures::PpeFixture; + include!(concat!(env!("OUT_DIR"), "/methods.rs")); diff --git a/tools/ppe_test_data_gen/Cargo.toml b/tools/ppe_test_data_gen/Cargo.toml new file mode 100644 index 00000000..27015776 --- /dev/null +++ b/tools/ppe_test_data_gen/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ppe_test_data_gen" +version = "0.1.0" +edition = "2024" +license = { workspace = true } +publish = false + +[lints] +workspace = true + +# Enabling `prove` links in the RISC0 prover backend. Without it the binary +# compiles but `execute_and_prove` will panic at runtime. +# Set RISC0_DEV_MODE=1 for fast mock proofs during development. +[features] +default = ["prove"] +prove = ["lee/prove", "risc0-zkvm/prove"] + +[dependencies] +lee = { workspace = true } +lee_core = { workspace = true, features = ["test_utils"] } +authenticated_transfer_core.workspace = true +risc0-zkvm.workspace = true +borsh.workspace = true +anyhow.workspace = true +clap = { workspace = true } diff --git a/tools/ppe_test_data_gen/src/main.rs b/tools/ppe_test_data_gen/src/main.rs new file mode 100644 index 00000000..560cee9f --- /dev/null +++ b/tools/ppe_test_data_gen/src/main.rs @@ -0,0 +1,193 @@ +//! Generates LEZ privacy-preserving execution (PPE) proof fixtures for aggregation testing. +//! +//! Each fixture bundles a `PrivacyPreservingCircuitOutput` (serialised with risc0 serde via +//! `to_bytes()`) and the raw `InnerReceipt` bytes (Borsh-encoded, from `Proof::into_inner()`). +//! The whole bundle is a Borsh-encoded `Vec`. +//! +//! Keys are derived deterministically from the proof index so the fixture file is +//! reproducible. +//! # Usage +//! +//! ```sh +//! # Fast mock proofs — good for iteration: +//! RISC0_DEV_MODE=1 cargo run --release -p ppe_test_data_gen -- --output ppe_fixtures.bin +//! +//! # Real STARK proofs (slow, production-quality): +//! cargo run --release -p ppe_test_data_gen -- --output ppe_fixtures.bin +//! ``` +//! +//! # Loading fixtures in aggregation code +//! +//! ```rust,ignore +//! let bytes = std::fs::read("ppe_fixtures.bin").unwrap(); +//! let fixtures: Vec = borsh::from_slice(&bytes).unwrap(); +//! +//! for f in &fixtures { +//! // Decode the circuit output: +//! let words: &[u32] = bytemuck::cast_slice(&f.output_bytes); +//! let output: PrivacyPreservingCircuitOutput = +//! risc0_zkvm::serde::from_slice(words).unwrap(); +//! +//! // Reconstruct the Receipt for use as an aggregation assumption: +//! let inner: risc0_zkvm::InnerReceipt = borsh::from_slice(&f.proof_bytes).unwrap(); +//! let receipt = risc0_zkvm::Receipt::new(inner, output.to_bytes()); +//! } +//! ``` + +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::print_stderr, + reason = "CLI tool — intentional index-to-byte casts, counter arithmetic, and diagnostic output" +)] + +use std::path::PathBuf; + +use anyhow::{Context as _, Result}; +use authenticated_transfer_core::Instruction; +use borsh::{BorshDeserialize, BorshSerialize}; +use clap::Parser; +use lee::{ + execute_and_prove, privacy_preserving_transaction::circuit::ProgramWithDependencies, + program::Program, +}; +use lee_core::{ + InputAccountIdentity, NullifierPublicKey, SharedSecretKey, + account::{Account, AccountId, AccountWithMetadata}, + encryption::ViewingPublicKey, +}; + +/// Mirror of `test_program_methods::PpeFixture`. Borsh field order must stay in sync. +#[derive(BorshSerialize, BorshDeserialize)] +struct PpeFixture { + label: String, + output_bytes: Vec, + proof_bytes: Vec, +} + +#[derive(Parser)] +#[command( + name = "ppe_test_data_gen", + about = "Generate PPE proof fixtures for aggregation testing" +)] +struct Cli { + /// Output file path for the Borsh-serialised fixture bundle. + #[arg(long, default_value = "ppe_fixtures.bin")] + output: PathBuf, + + /// Number of independent PPE proofs to generate. + #[arg(long, default_value_t = 16)] + count: usize, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let program = Program::authenticated_transfer_program(); + let mut fixtures: Vec = Vec::with_capacity(cli.count); + + for i in 0..cli.count { + let lo = (i & 0xFF) as u8; + let hi = ((i >> 8) & 0xFF) as u8; + + // Non-zero bases ensure no key is accidentally all-zero. + let mut nsk = [41_u8; 32]; + nsk[0] = lo; + nsk[1] = hi; + + // ViewingPublicKey requires two independent 32-byte seed halves (d, z). + let mut d = [42_u8; 32]; + d[0] = lo; + d[1] = hi; + let mut z = [43_u8; 32]; + z[0] = lo; + z[1] = hi; + + // The message hash used for deterministic encapsulation; vary it per proof index. + let mut msg = [44_u8; 32]; + msg[0] = lo; + msg[1] = hi; + + let amount: u128 = 100; + let label = format!("public_to_private_{i}"); + + let vpk = ViewingPublicKey::from_seed(&d, &z); + let npk = NullifierPublicKey::from(&nsk); + // `encapsulate_deterministic` requires `lee_core` with `test_utils` feature. + // The recipient output is at index 0 (the only private output in this scenario). + let (ssk, _epk) = SharedSecretKey::encapsulate_deterministic(&vpk, &msg, 0); + + let mut sender_seed = [45_u8; 32]; + sender_seed[0] = lo; + sender_seed[1] = hi; + + let sender = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: amount + 10, + ..Account::default() + }, + true, + AccountId::new(sender_seed), + ); + let recipient = AccountWithMetadata::new( + Account::default(), + false, + AccountId::for_regular_private_account(&npk, 0), + ); + + let instruction = + Program::serialize_instruction(Instruction::Transfer { amount }) + .context("serialise instruction")?; + + eprintln!( + "[ppe_test_data_gen] ({}/{}) proving '{label}' ...", + i + 1, + cli.count, + ); + + let (output, proof) = execute_and_prove( + vec![sender, recipient], + instruction, + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk, + ssk, + identifier: 0, + }, + ], + &ProgramWithDependencies::from(program.clone()), + ) + .with_context(|| format!("execute_and_prove for '{label}'"))?; + + let proof_bytes = proof.into_inner(); + let output_bytes = output.to_bytes(); + + eprintln!( + "[ppe_test_data_gen] proof={} B output={} B commitments={} ciphertexts={}", + proof_bytes.len(), + output_bytes.len(), + output.new_commitments.len(), + output.ciphertexts.len(), + ); + + fixtures.push(PpeFixture { + label, + output_bytes, + proof_bytes, + }); + } + + let bundle = borsh::to_vec(&fixtures).context("serialise fixture bundle")?; + std::fs::write(&cli.output, &bundle).context("write output file")?; + + eprintln!( + "[ppe_test_data_gen] wrote {} fixtures ({} bytes total) -> {}", + fixtures.len(), + bundle.len(), + cli.output.display(), + ); + + Ok(()) +}