initialize tests

This commit is contained in:
Marvin Jones 2026-06-05 16:52:30 -04:00
parent 4577f2cbcd
commit 2620c42ab4
11 changed files with 596 additions and 0 deletions

15
Cargo.lock generated
View File

@ -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",

View File

@ -48,6 +48,7 @@ members = [
"tools/cycle_bench",
"tools/crypto_primitives_bench",
"tools/integration_bench",
"tools/ppe_test_data_gen",
]
[workspace.dependencies]

50
bench_ppe_aggregation.sh Executable file
View File

@ -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"

View File

@ -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<Vec<u8>> = 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<PrivacyPreservingCircuitOutput> =
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<PrivacyPreservingCircuitOutput> =
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();

View File

@ -7,6 +7,9 @@ license = { workspace = true }
[lints]
workspace = true
[dependencies]
borsh.workspace = true
[build-dependencies]
risc0-build.workspace = true

View File

@ -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 }

View File

@ -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<PrivacyPreservingCircuitOutput>` — 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<u8> = 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);
}

View File

@ -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<u8>,
/// Borsh-encoded `InnerReceipt` (from `Proof::into_inner()`).
pub proof_bytes: Vec<u8>,
}
impl PpeFixture {
/// Loads a Borsh-encoded `Vec<PpeFixture>` 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<Self> {
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")
}
}

View File

@ -1 +1,4 @@
pub mod fixtures;
pub use fixtures::PpeFixture;
include!(concat!(env!("OUT_DIR"), "/methods.rs"));

View File

@ -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 }

View File

@ -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<PpeFixture>`.
//!
//! 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<PpeFixture> = 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<u8>,
proof_bytes: Vec<u8>,
}
#[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<PpeFixture> = 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(())
}