cleaned up sequencer_aggregator circuit

This commit is contained in:
Marvin Jones 2026-06-12 18:20:46 -04:00
parent 038dba11f1
commit 70ceed0adc
21 changed files with 736 additions and 1135 deletions

View File

@ -1,74 +0,0 @@
#!/usr/bin/env bash
# Benchmarks the aggregator circuit (core and strict variants) with CUDA acceleration.
#
# Fixtures must be generated first:
# cargo run --release -p ppe_test_data_gen -- --tx-output ppe_tx_fixtures.bin
#
# Usage:
# ./bench_aggregator_cuda.sh
#
# Environment:
# PPE_TX_FIXTURES — path to fixture file (default: ppe_tx_fixtures.bin)
# COUNTS — space-separated list of transaction counts (default: "1 3 5")
set -euo pipefail
# Point the build at CUDA 13.0 (required for Blackwell / compute_120).
export NVCC=/usr/local/cuda-13.0/bin/nvcc
export CUDA_HOME=/usr/local/cuda-13.0
export PATH="/usr/local/cuda-13.0/bin:$PATH"
FIXTURES="$(realpath "${PPE_TX_FIXTURES:-ppe_tx_fixtures.bin}")"
COUNTS="${COUNTS:-2 3 4 5 6 7 8 10 12 14 16}"
SEGMENT_LIMIT_PO2="${PPE_SEGMENT_LIMIT_PO2-19}"
if [ ! -f "$FIXTURES" ]; then
echo "ERROR: fixture file '$FIXTURES' not found."
echo "Generate it first:"
echo " cargo run --release -p ppe_test_data_gen -- --tx-output $FIXTURES"
exit 1
fi
printf "\n%-6s %-8s %14s %20s\n" "n" "variant" "proving_ms" "proof_size_bytes"
printf "%-6s %-8s %14s %20s\n" "------" "--------" "--------------" "--------------------"
run_bench() {
local count=$1
local strict=$2
local variant
variant=$([ "$strict" = "1" ] && echo "strict" || echo "core")
local segment_limit_env=()
if [ -n "$SEGMENT_LIMIT_PO2" ]; then
segment_limit_env=(PPE_SEGMENT_LIMIT_PO2="$SEGMENT_LIMIT_PO2")
fi
local line
line=$(
env \
PPE_TX_FIXTURES="$FIXTURES" \
AGGREGATOR_COUNT="$count" \
AGGREGATOR_STRICT="$strict" \
"${segment_limit_env[@]}" \
cargo test -p lee --features cuda,prove bench_aggregator -- --nocapture 2>&1 \
| grep "\[lee::analytics\] aggregator" || true
)
if [ -z "$line" ]; then
printf "%-6s %-8s %14s %20s\n" "$count" "$variant" "failed" "-"
return
fi
local proving_ms proof_size
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 %-8s %14s %20s\n" "$count" "$variant" "$proving_ms" "$proof_size"
}
for count in $COUNTS; do
run_bench "$count" "0"
run_bench "$count" "1"
done
printf "\n"

View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Benchmarks the sequencer aggregator host/guest pair (sequencer_aggregator.rs) with CUDA
# acceleration.
#
# Test transactions are loaded from a cache (target/sequencer_aggregator_bench_transactions.bin,
# BENCH_MAX_TRANSACTIONS=8 by default); `AGGREGATOR_COUNT` truncates that cached set, so this
# script does NOT regenerate transactions. If the cache doesn't exist yet, generate it first
# (one-time cost, produces real, non-dev-mode PPE proofs):
#
# NVCC=/usr/local/cuda-13.0/bin/nvcc \
# CUDA_HOME=/usr/local/cuda-13.0 \
# PATH="/usr/local/cuda-13.0/bin:$PATH" \
# PPE_SEGMENT_LIMIT_PO2=19 \
# cargo test -p lee --features cuda,prove --lib \
# sequencer_aggregator::tests::bench_sequencer_aggregator -- --nocapture
#
# Usage:
# ./bench_sequencer_aggregator_cuda.sh
#
# Environment:
# COUNTS — space-separated list of transaction counts (default: "2 4 8"); each
# must be <= BENCH_MAX_TRANSACTIONS in sequencer_aggregator.rs
# PPE_SEGMENT_LIMIT_PO2 — segment size limit (log2 cycles/segment) passed to the executor
# (default: 19)
set -euo pipefail
# Point the build at CUDA 13.0 (required for Blackwell / compute_120).
export NVCC=/usr/local/cuda-13.0/bin/nvcc
export CUDA_HOME=/usr/local/cuda-13.0
export PATH="/usr/local/cuda-13.0/bin:$PATH"
COUNTS="${COUNTS:-2 4 8}"
export PPE_SEGMENT_LIMIT_PO2="${PPE_SEGMENT_LIMIT_PO2-19}"
printf "\n%-6s %14s %20s\n" "n" "proving_ms" "proof_size_bytes"
printf "%-6s %14s %20s\n" "------" "--------------" "--------------------"
run_bench() {
local count=$1
local line
line=$(
AGGREGATOR_COUNT="$count" \
cargo test -p lee --features cuda,prove --lib \
sequencer_aggregator::tests::bench_sequencer_aggregator -- --nocapture 2>&1 \
| grep "\[lee::analytics\] sequencer_aggregator" || true
)
if [ -z "$line" ]; then
printf "%-6s %14s %20s\n" "$count" "failed" "-"
return
fi
local proving_ms proof_size
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" "$count" "$proving_ms" "$proof_size"
}
for count in $COUNTS; do
run_bench "$count"
done
printf "\n"

View File

@ -1,35 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::{BlockId, PrivacyPreservingCircuitOutput, Timestamp, program::ProgramId};
/// Input to the aggregator circuit.
#[derive(Serialize, Deserialize)]
pub struct AggregatorCircuitInput {
/// Image ID of the privacy-preserving circuit. Passed as a runtime value so the
/// guest does not need a compile-time dependency on the image ID.
pub privacy_preserving_circuit_id: ProgramId,
pub block_id: BlockId,
pub timestamp: Timestamp,
pub circuit_outputs: Vec<PrivacyPreservingCircuitOutput>,
}
/// Output committed to the journal by the aggregator circuit.
///
/// Preserves the full `PrivacyPreservingCircuitOutput` for each transaction so observers
/// can perform state-dependent checks (nonces, commitment freshness, nullifier uniqueness)
/// independently. Only the individual proofs are dropped.
#[derive(Serialize, Deserialize)]
#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))]
pub struct AggregatorCircuitOutput {
pub block_id: BlockId,
pub timestamp: Timestamp,
pub circuit_outputs: Vec<PrivacyPreservingCircuitOutput>,
}
#[cfg(feature = "host")]
impl AggregatorCircuitOutput {
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
bytemuck::cast_slice(&risc0_zkvm::serde::to_vec(self).unwrap()).to_vec()
}
}

View File

@ -3,7 +3,6 @@
reason = "We prefer to group methods by functionality rather than by type for encoding"
)]
pub use aggregator_circuit_io::{AggregatorCircuitInput, AggregatorCircuitOutput};
pub use circuit_io::{
InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput,
};
@ -14,15 +13,17 @@ pub use commitment::{
pub use encryption::{EncryptionScheme, SharedSecretKey};
pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey};
pub use program::PrivateAccountKind;
pub use sequencer_aggregator_io::SequencerAggregatorOutput;
pub mod account;
mod aggregator_circuit_io;
mod circuit_io;
mod commitment;
mod encoding;
pub mod encryption;
pub mod message;
mod nullifier;
pub mod program;
mod sequencer_aggregator_io;
#[cfg(feature = "host")]
pub mod error;

View File

@ -0,0 +1,68 @@
//! Guest-side mirror of `lee::privacy_preserving_transaction::message::{Message,
//! EncryptedAccountData}`.
//!
//! The aggregator guest cannot depend on the `lee` crate (it pulls in host-only
//! `risc0-zkvm`/`lee_core` features), so the host converts each transaction's `Message` into
//! this `lee_core`-resident mirror before writing it to the guest. The mirror omits `epk`
//! (the 1088-byte ML-KEM-768 ciphertext from `EphemeralPublicKey`): it isn't part of
//! [`PrivacyPreservingCircuitOutput`] and so plays no role in `env::verify`, and reading it
//! as `Vec<u8>` is costly enough to push the guest over a segment boundary.
use serde::{Deserialize, Serialize};
use crate::{
Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput,
account::{Account, AccountId, AccountWithMetadata, Nonce},
encryption::Ciphertext,
program::{BlockValidityWindow, TimestampValidityWindow},
};
/// Mirror of `lee::privacy_preserving_transaction::message::EncryptedAccountData`.
#[derive(Serialize, Deserialize)]
#[cfg_attr(any(feature = "host", test), derive(Debug, Clone, PartialEq, Eq))]
pub struct EncryptedAccountData {
pub ciphertext: Ciphertext,
pub view_tag: u8,
}
/// Mirror of `lee::privacy_preserving_transaction::message::Message`.
#[derive(Serialize, Deserialize)]
#[cfg_attr(any(feature = "host", test), derive(Debug, Clone, PartialEq, Eq))]
pub struct Message {
pub public_account_ids: Vec<AccountId>,
pub nonces: Vec<Nonce>,
pub public_post_states: Vec<Account>,
pub encrypted_private_post_states: Vec<EncryptedAccountData>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub block_validity_window: BlockValidityWindow,
pub timestamp_validity_window: TimestampValidityWindow,
}
impl Message {
/// Reconstructs the `PrivacyPreservingCircuitOutput` this message corresponds to, given
/// the `public_pre_states` resolved for `public_account_ids` (same order).
///
/// Mirrors `lee`'s `circuit_output_for_message`, minus the `public_pre_states` lookup
/// itself: the guest has no access to chain state, so the caller resolves pre-states and
/// passes them in directly.
#[must_use]
pub fn into_circuit_output(
self,
public_pre_states: Vec<AccountWithMetadata>,
) -> PrivacyPreservingCircuitOutput {
PrivacyPreservingCircuitOutput {
public_pre_states,
public_post_states: self.public_post_states,
ciphertexts: self
.encrypted_private_post_states
.into_iter()
.map(|data| data.ciphertext)
.collect(),
new_commitments: self.new_commitments,
new_nullifiers: self.new_nullifiers,
block_validity_window: self.block_validity_window,
timestamp_validity_window: self.timestamp_validity_window,
}
}
}

View File

@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
use crate::{BlockId, Timestamp, message::Message};
/// Output committed to the journal by the sequencer aggregator circuit.
#[derive(Serialize, Deserialize)]
#[cfg_attr(any(feature = "host", test), derive(Debug, Clone, PartialEq, Eq))]
pub struct SequencerAggregatorOutput {
pub block_id: BlockId,
pub timestamp: Timestamp,
pub messages: Vec<Message>,
}

View File

@ -1,491 +0,0 @@
//! Host-side aggregator circuit: batches multiple privacy-preserving circuit proofs into
//! a single aggregated proof.
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
AggregatorCircuitInput, AggregatorCircuitOutput, BlockId, Commitment, Nullifier,
PrivacyPreservingCircuitOutput, Timestamp, account::AccountId,
};
use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover};
use crate::{
PrivacyPreservingTransaction, V03State,
error::LeeError,
program_methods::PRIVACY_PRESERVING_CIRCUIT_ID,
validated_state_diff::circuit_output_for_message,
};
/// Proof produced by the aggregator circuit.
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct AggregatorProof(Vec<u8>);
impl AggregatorProof {
#[must_use]
pub fn into_inner(self) -> Vec<u8> {
self.0
}
#[must_use]
pub const fn from_inner(inner: Vec<u8>) -> Self {
Self(inner)
}
#[must_use]
pub fn is_valid_for(&self, output: &AggregatorCircuitOutput, circuit_id: [u32; 8]) -> bool {
verify_proof(&self.0, output, circuit_id)
}
}
fn verify_proof(
proof_bytes: &[u8],
output: &AggregatorCircuitOutput,
circuit_id: [u32; 8],
) -> bool {
let Ok(inner) = borsh::from_slice::<InnerReceipt>(proof_bytes) else {
return false;
};
let receipt = Receipt::new(inner, output.to_bytes());
receipt.verify(circuit_id).is_ok()
}
/// Filters `input_txs` down to the subset that can be aggregated together in one batch.
///
/// Each transaction is independently re-validated against `state` via
/// [`ValidatedStateDiff::from_privacy_preserving_transaction`] (signatures, nonces, validity
/// windows, proof, commitment/nullifier checks); transactions that fail this check are dropped.
///
/// Surviving transactions are then checked against every other surviving transaction so far,
/// mirroring the cross-transaction `assert!`s in the aggregator guests
/// (`program_methods/guest/src/bin/aggregator_circuit{,_strict}/main.rs`): a transaction is
/// dropped if it:
/// - reuses a nullifier already spent by an earlier transaction in this batch,
/// - reuses a commitment already created by an earlier transaction in this batch, or
/// - updates a public account already updated by an earlier transaction in this batch.
///
/// Returns the surviving transactions paired with the `PrivacyPreservingCircuitOutput` each
/// one's proof commits to, in input order. This filtering only depends on `state`, not on the
/// prover, so it can run anywhere `state` is available (e.g. ahead of batch construction).
#[must_use]
pub fn select_aggregatable_transactions(
input_txs: Vec<PrivacyPreservingTransaction>,
state: &V03State,
_block_id: BlockId,
_timestamp: Timestamp,
) -> Vec<(PrivacyPreservingTransaction, PrivacyPreservingCircuitOutput)> {
let mut accepted = Vec::new();
let mut seen_nullifiers: Vec<Nullifier> = Vec::new();
let mut seen_commitments: Vec<Commitment> = Vec::new();
let mut seen_updated_account_ids: Vec<AccountId> = Vec::new();
for tx in input_txs {
/*
if let Err(e) =
ValidatedStateDiff::from_privacy_preserving_transaction(&tx, state, block_id, timestamp)
{
eprintln!("[DEBUG] tx dropped by from_privacy_preserving_transaction: {e}");
continue;
}*/
let signer_account_ids = tx.signer_account_ids();
let circuit_output = circuit_output_for_message(state, &tx.message, &signer_account_ids);
let updated_account_ids = || {
circuit_output
.public_pre_states
.iter()
.zip(circuit_output.public_post_states.iter())
.filter(|(pre_state, post_state)| pre_state.account != **post_state)
.map(|(pre_state, _)| pre_state.account_id)
};
let has_duplicate_nullifier = circuit_output
.new_nullifiers
.iter()
.any(|(nullifier, _)| seen_nullifiers.contains(nullifier));
let has_duplicate_commitment = circuit_output
.new_commitments
.iter()
.any(|commitment| seen_commitments.contains(commitment));
let has_duplicate_account_update =
updated_account_ids().any(|account_id| seen_updated_account_ids.contains(&account_id));
if has_duplicate_nullifier || has_duplicate_commitment || has_duplicate_account_update {
continue;
}
seen_nullifiers.extend(circuit_output.new_nullifiers.iter().map(|(n, _)| *n));
seen_commitments.extend(circuit_output.new_commitments.iter().cloned());
seen_updated_account_ids.extend(updated_account_ids());
accepted.push((tx, circuit_output));
}
accepted
}
/// Aggregates privacy-preserving circuit proofs into a single proof.
///
/// `input_txs` is first filtered down via [`select_aggregatable_transactions`]; only the
/// surviving transactions are proven against.
///
/// `elf` is the compiled aggregator circuit binary. Use
/// `lee::program_methods::AGGREGATOR_CIRCUIT_ELF` for the core circuit or
/// `AGGREGATOR_CIRCUIT_STRICT_ELF` for the strict variant.
pub fn aggregate(
block_id: BlockId,
timestamp: Timestamp,
input_txs: Vec<PrivacyPreservingTransaction>,
state: &V03State,
elf: &[u8],
segment_limit_po2: Option<u32>,
) -> Result<(AggregatorCircuitOutput, AggregatorProof), LeeError> {
run_aggregator(block_id, timestamp, input_txs, state, elf, segment_limit_po2)
}
fn run_aggregator(
block_id: BlockId,
timestamp: Timestamp,
input_txs: Vec<PrivacyPreservingTransaction>,
state: &V03State,
elf: &[u8],
segment_limit_po2: Option<u32>,
) -> Result<(AggregatorCircuitOutput, AggregatorProof), LeeError> {
let mut env_builder = ExecutorEnv::builder();
if let Some(po2) = segment_limit_po2 {
env_builder.segment_limit_po2(po2);
}
let input_len = input_txs.len();
let mut circuit_outputs = Vec::new();
for (tx, circuit_output) in select_aggregatable_transactions(input_txs, state, block_id, timestamp) {
let inner = borsh::from_slice::<InnerReceipt>(&tx.witness_set.proof.into_inner())
.map_err(|e| LeeError::CircuitOutputDeserializationError(e.to_string()))?;
env_builder.add_assumption(Receipt::new(inner, circuit_output.to_bytes()));
circuit_outputs.push(circuit_output);
}
eprintln!(
"[DEBUG] select_aggregatable_transactions: input_len={input_len} accepted={}",
circuit_outputs.len()
);
let input = AggregatorCircuitInput {
privacy_preserving_circuit_id: PRIVACY_PRESERVING_CIRCUIT_ID,
block_id,
timestamp,
circuit_outputs,
};
env_builder
.write(&input)
.map_err(|e| LeeError::CircuitProvingError(e.to_string()))?;
let env = env_builder
.build()
.map_err(|e| LeeError::CircuitProvingError(e.to_string()))?;
let prove_info = default_prover()
// TODO: succinct compresses all segments into one receipt via recursion — consider
// ProverOpts::composite() (no recursion, one receipt per segment) if proving speed
// matters more than proof size.
.prove_with_opts(env, elf, &ProverOpts::succinct())
.map_err(|e| LeeError::CircuitProvingError(e.to_string()))?;
let proof = AggregatorProof(borsh::to_vec(&prove_info.receipt.inner)?);
let output: AggregatorCircuitOutput = prove_info
.receipt
.journal
.decode()
.map_err(|e| LeeError::CircuitOutputDeserializationError(e.to_string()))?;
Ok((output, proof))
}
#[cfg(test)]
mod tests {
use lee_core::{BlockId, Timestamp};
use test_program_methods::{PpeFixture, PpeTxFixtureBundle};
use super::aggregate;
use crate::{
PrivacyPreservingTransaction, V03State,
program_methods::{
AGGREGATOR_CIRCUIT_ELF, AGGREGATOR_CIRCUIT_ID, AGGREGATOR_CIRCUIT_STRICT_ELF,
AGGREGATOR_CIRCUIT_STRICT_ID,
},
};
/// Benchmark: aggregate N pre-generated PPE transactions loaded from a fixture file.
///
/// Generate fixtures first:
///
/// ```sh
/// cargo run --release -p ppe_test_data_gen -- --tx-output ppe_tx_fixtures.bin
/// ```
///
/// Control via env vars:
/// - `PPE_TX_FIXTURES`: path to fixture file (default: `ppe_tx_fixtures.bin`).
/// - `AGGREGATOR_COUNT`: number of fixtures to use (default: all).
/// - `AGGREGATOR_STRICT`: set to "1" for the strict variant (default: core).
///
/// Skips gracefully when the fixture file is absent.
///
/// Output line (captured by `bench_aggregator_cuda.sh`):
/// `[lee::analytics] aggregator n=… variant=… proving_ms=… proof_size_bytes=…`.
#[test]
fn bench_aggregator() {
let path = std::env::var("PPE_TX_FIXTURES")
.unwrap_or_else(|_| "ppe_tx_fixtures.bin".to_owned());
let Some(bundle) = PpeTxFixtureBundle::load_bundle(&path) else {
return;
};
let state: V03State =
borsh::from_slice(&bundle.state_bytes).expect("fixture state_bytes invalid");
let mut transactions: Vec<PrivacyPreservingTransaction> = bundle
.tx_bytes
.iter()
.map(|bytes| borsh::from_slice(bytes).expect("fixture tx_bytes invalid"))
.collect();
if transactions.is_empty() {
return;
}
if let Ok(s) = std::env::var("AGGREGATOR_COUNT") {
let count: usize = s.parse().expect("AGGREGATOR_COUNT must be a number");
transactions.truncate(count);
}
let strict: bool = std::env::var("AGGREGATOR_STRICT")
.map(|s| s == "1" || s == "true")
.unwrap_or(false);
let (elf, circuit_id) = if strict {
(AGGREGATOR_CIRCUIT_STRICT_ELF, AGGREGATOR_CIRCUIT_STRICT_ID)
} else {
(AGGREGATOR_CIRCUIT_ELF, AGGREGATOR_CIRCUIT_ID)
};
let block_id: BlockId = bundle.block_id;
let timestamp: Timestamp = bundle.timestamp;
let segment_limit_po2: Option<u32> = std::env::var("PPE_SEGMENT_LIMIT_PO2")
.ok()
.map(|s| s.parse().expect("PPE_SEGMENT_LIMIT_PO2 must be a number"));
let n = transactions.len();
let t0 = std::time::Instant::now();
let (_, agg_proof) = aggregate(
block_id,
timestamp,
transactions,
&state,
elf,
segment_limit_po2,
)
.expect("aggregation should succeed");
let proving_ms = t0.elapsed().as_millis();
let variant = if strict { "strict" } else { "core" };
let proof_size = agg_proof.into_inner().len();
#[expect(clippy::print_stderr, reason = "benchmark result line consumed by tooling")]
{
eprintln!(
"[lee::analytics] aggregator n={n} variant={variant} proving_ms={proving_ms} proof_size_bytes={proof_size}",
);
}
let _ = circuit_id;
}
/// Diagnostic: does `circuit_output_for_message`'s reconstruction of `public_pre_states[0]`
/// from a fresh `V03State::new_with_genesis_accounts` genesis state match the
/// `AccountWithMetadata` that `ppe_test_data_gen` originally fed to `execute_and_prove`?
/// No proving involved — pure host-side construction, to isolate whether the
/// "Invalid privacy preserving execution circuit proof" failure comes from this
/// reconstruction step.
#[test]
fn debug_circuit_output_for_message_pre_state_reconstruction() {
use lee_core::account::{Account, AccountId, AccountWithMetadata};
use crate::{PrivateKey, PublicKey, program::Program};
let program = Program::authenticated_transfer_program();
let signing_key = PrivateKey::try_new([50_u8; 32]).expect("valid seed");
let sender_account_id = AccountId::from(&PublicKey::new_from_private_key(&signing_key));
let original = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 110,
..Account::default()
},
true,
sender_account_id,
);
let state = V03State::new_with_genesis_accounts(
&[(sender_account_id, 110)],
vec![],
1_700_000_000,
);
let signer_account_ids = vec![sender_account_id];
let reconstructed = AccountWithMetadata::new(
state.get_account_by_id(sender_account_id),
signer_account_ids.contains(&sender_account_id),
sender_account_id,
);
assert_eq!(
original, reconstructed,
"public_pre_states[0] reconstruction mismatch"
);
}
/// Diagnostic: for fixture index 0, does `circuit_output_for_message`'s reconstruction
/// (from `ppe_tx_fixtures.bin`'s state + transaction) match the actual
/// `PrivacyPreservingCircuitOutput` that was proven (from `ppe_fixtures.bin`)?
/// Field-by-field, to localize a journal mismatch if `proof.is_valid_for(...)` fails.
#[test]
fn debug_circuit_output_for_message_matches_proven_output() {
use lee_core::PrivacyPreservingCircuitOutput;
use super::circuit_output_for_message;
let fixtures_path =
std::env::var("PPE_FIXTURES").unwrap_or_else(|_| "ppe_fixtures.bin".to_owned());
let fixtures = PpeFixture::load_bundle(&fixtures_path);
let Some(fixture) = fixtures.first() else {
return;
};
let words: &[u32] = bytemuck::cast_slice(&fixture.output_bytes);
let original: PrivacyPreservingCircuitOutput =
risc0_zkvm::serde::from_slice(words).expect("output_bytes should decode");
let tx_path = std::env::var("PPE_TX_FIXTURES")
.unwrap_or_else(|_| "ppe_tx_fixtures.bin".to_owned());
let Some(bundle) = PpeTxFixtureBundle::load_bundle(&tx_path) else {
return;
};
let state: V03State =
borsh::from_slice(&bundle.state_bytes).expect("fixture state_bytes invalid");
let tx: PrivacyPreservingTransaction =
borsh::from_slice(&bundle.tx_bytes[0]).expect("fixture tx_bytes invalid");
let signer_account_ids = tx.signer_account_ids();
let reconstructed = circuit_output_for_message(&state, &tx.message, &signer_account_ids);
assert_eq!(
original.public_pre_states, reconstructed.public_pre_states,
"public_pre_states mismatch"
);
assert_eq!(
original.public_post_states, reconstructed.public_post_states,
"public_post_states mismatch"
);
assert_eq!(
original.ciphertexts, reconstructed.ciphertexts,
"ciphertexts mismatch"
);
assert_eq!(
original.new_commitments, reconstructed.new_commitments,
"new_commitments mismatch"
);
assert_eq!(
original.new_nullifiers, reconstructed.new_nullifiers,
"new_nullifiers mismatch"
);
assert_eq!(
original.block_validity_window, reconstructed.block_validity_window,
"block_validity_window mismatch"
);
assert_eq!(
original.timestamp_validity_window, reconstructed.timestamp_validity_window,
"timestamp_validity_window mismatch"
);
assert_eq!(original, reconstructed, "full PrivacyPreservingCircuitOutput mismatch");
}
/// Diagnostic: run the real `from_privacy_preserving_transaction` check (signatures,
/// nonces, validity windows, proof verification, commitment/nullifier freshness) against
/// fixture index 0 of `ppe_tx_fixtures.bin`, in isolation, to see exactly which sub-check
/// fails (if any) for the current fixtures.
#[test]
fn debug_from_privacy_preserving_transaction_fixture0() {
use crate::validated_state_diff::ValidatedStateDiff;
let tx_path = std::env::var("PPE_TX_FIXTURES")
.unwrap_or_else(|_| "ppe_tx_fixtures.bin".to_owned());
let Some(bundle) = PpeTxFixtureBundle::load_bundle(&tx_path) else {
return;
};
let state: V03State =
borsh::from_slice(&bundle.state_bytes).expect("fixture state_bytes invalid");
let tx: PrivacyPreservingTransaction =
borsh::from_slice(&bundle.tx_bytes[0]).expect("fixture tx_bytes invalid");
let result = ValidatedStateDiff::from_privacy_preserving_transaction(
&tx,
&state,
bundle.block_id,
bundle.timestamp,
);
match &result {
Ok(_) => eprintln!("[DEBUG] fixture 0: from_privacy_preserving_transaction Ok"),
Err(e) => eprintln!("[DEBUG] fixture 0: from_privacy_preserving_transaction Err: {e}"),
}
assert!(result.is_ok());
}
/// Diagnostic: drill into `Proof::is_valid_for` for fixture index 0 — does the proof
/// bytes blob even decode as `InnerReceipt`, does `receipt.verify(...)` succeed against
/// the reconstructed journal, and do the raw proof bytes match `ppe_fixtures.bin`'s
/// `proof_bytes` for the same index?
#[test]
fn debug_proof_is_valid_for_fixture0() {
use super::circuit_output_for_message;
let tx_path = std::env::var("PPE_TX_FIXTURES")
.unwrap_or_else(|_| "ppe_tx_fixtures.bin".to_owned());
let Some(bundle) = PpeTxFixtureBundle::load_bundle(&tx_path) else {
return;
};
let state: V03State =
borsh::from_slice(&bundle.state_bytes).expect("fixture state_bytes invalid");
let tx: PrivacyPreservingTransaction =
borsh::from_slice(&bundle.tx_bytes[0]).expect("fixture tx_bytes invalid");
let signer_account_ids = tx.signer_account_ids();
let reconstructed = circuit_output_for_message(&state, &tx.message, &signer_account_ids);
let proof_bytes = tx.witness_set.proof.0.clone();
eprintln!("[DEBUG] tx.witness_set.proof bytes len = {}", proof_bytes.len());
match borsh::from_slice::<risc0_zkvm::InnerReceipt>(&proof_bytes) {
Ok(inner) => {
eprintln!("[DEBUG] proof bytes decode as InnerReceipt: Ok");
let receipt = risc0_zkvm::Receipt::new(inner, reconstructed.to_bytes());
match receipt.verify(crate::program_methods::PRIVACY_PRESERVING_CIRCUIT_ID) {
Ok(()) => eprintln!("[DEBUG] receipt.verify: Ok"),
Err(e) => eprintln!("[DEBUG] receipt.verify: Err: {e}"),
}
}
Err(e) => eprintln!("[DEBUG] proof bytes decode as InnerReceipt: Err: {e}"),
}
let fixtures_path =
std::env::var("PPE_FIXTURES").unwrap_or_else(|_| "ppe_fixtures.bin".to_owned());
let fixtures = PpeFixture::load_bundle(&fixtures_path);
if let Some(fixture) = fixtures.first() {
eprintln!(
"[DEBUG] fixture.proof_bytes len = {}, tx proof bytes == fixture.proof_bytes: {}",
fixture.proof_bytes.len(),
proof_bytes == fixture.proof_bytes
);
eprintln!(
"[DEBUG] fixture.output_bytes len = {}, reconstructed.to_bytes() == fixture.output_bytes: {}",
fixture.output_bytes.len(),
reconstructed.to_bytes() == fixture.output_bytes
);
}
}
}

View File

@ -22,7 +22,6 @@ pub use state::{
};
pub use validated_state_diff::ValidatedStateDiff;
pub mod aggregator_circuit;
pub mod encoding;
pub mod error;
mod merkle_tree;
@ -30,6 +29,7 @@ pub mod privacy_preserving_transaction;
pub mod program;
pub mod program_deployment_transaction;
pub mod public_transaction;
pub mod sequencer_aggregator;
mod signature;
mod state;
mod validated_state_diff;

View File

@ -985,8 +985,7 @@ mod tests {
env_builder.write(&PRIVACY_PRESERVING_CIRCUIT_ID).unwrap();
// Outputs are written once as a word-native `Vec<&PrivacyPreservingCircuitOutput>`
// (matching `aggregator_circuit`'s `AggregatorCircuitInput`) instead of N raw
// `Vec<u8>` journal buffers — see the ppe_aggregation guest for why.
// instead of N raw `Vec<u8>` journal buffers — see the ppe_aggregation guest for why.
let outputs: Vec<&PrivacyPreservingCircuitOutput> = proofs.iter().map(|(o, _)| o).collect();
env_builder.write(&outputs).unwrap();
@ -1062,8 +1061,7 @@ mod tests {
env_builder.write(&PRIVACY_PRESERVING_CIRCUIT_ID).unwrap();
// Outputs are written once as a word-native `Vec<PrivacyPreservingCircuitOutput>`
// (matching `aggregator_circuit`'s `AggregatorCircuitInput`) instead of N raw
// `Vec<u8>` journal buffers — see the ppe_aggregation guest for why.
// instead of N raw `Vec<u8>` journal buffers — see the ppe_aggregation guest for why.
let outputs: Vec<PrivacyPreservingCircuitOutput> = fixtures
.iter()
.map(|f| {

View File

@ -0,0 +1,474 @@
//! TODO: redo-description (Marvin-pq)
//! Clean-slate host/guest pair for the sequencer's transaction aggregator.
//!
//! The guest cannot depend on the `lee` crate, so the host converts each transaction's
//! [`Message`] into its `lee_core`-resident mirror ([`lee_core::message::Message`]) and
//! writes it to the guest alongside the `public_pre_states` resolved for its
//! `public_account_ids`. The guest reconstructs the `PrivacyPreservingCircuitOutput` each
//! message corresponds to (`Message::into_circuit_output`), verifies the transaction's PPE
//! proof against it (as a recursive assumption added by the host), checks nullifier/
//! commitment/account-update uniqueness and block/timestamp validity windows across the
//! batch, and commits the verified messages alongside `block_id`/`timestamp` as its journal.
use lee_core::{
BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, SequencerAggregatorOutput,
Timestamp,
account::{AccountId, AccountWithMetadata},
};
use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover};
use crate::{
PRIVACY_PRESERVING_CIRCUIT_ID, PrivacyPreservingTransaction, error::LeeError,
privacy_preserving_transaction::message::Message,
};
/// Converts `message` into its `lee_core`-resident mirror for the guest. `epk` is dropped
/// (see [`lee_core::message`]'s module docs).
fn to_aggregator_message(message: &Message) -> lee_core::message::Message {
lee_core::message::Message {
public_account_ids: message.public_account_ids.clone(),
nonces: message.nonces.clone(),
public_post_states: message.public_post_states.clone(),
encrypted_private_post_states: message
.encrypted_private_post_states
.iter()
.map(|data| lee_core::message::EncryptedAccountData {
ciphertext: data.ciphertext.clone(),
view_tag: data.view_tag,
})
.collect(),
new_commitments: message.new_commitments.clone(),
new_nullifiers: message.new_nullifiers.clone(),
block_validity_window: message.block_validity_window,
timestamp_validity_window: message.timestamp_validity_window,
}
}
/// Filters `input_txs` down to the subset that can be aggregated together in one batch.
///
/// `public_pre_states[i]` must be the resolved `AccountWithMetadata` for each of
/// `input_txs[i].message().public_account_ids`, in the same order (i.e. the
/// `public_pre_states` of the `PrivacyPreservingCircuitOutput` `input_txs[i]`'s proof was
/// generated for).
///
/// A transaction is dropped if it reuses a nullifier or commitment already claimed earlier in
/// the batch, or updates a public account already updated earlier in the batch. The guest
/// re-asserts these same checks as defense-in-depth.
///
/// Returns the surviving transactions, each paired with its `lee_core`-resident message and
/// the `PrivacyPreservingCircuitOutput` its proof commits to, in input order.
fn select_aggregatable_transactions(
input_txs: Vec<PrivacyPreservingTransaction>,
public_pre_states: Vec<Vec<AccountWithMetadata>>,
) -> Vec<(
PrivacyPreservingTransaction,
lee_core::message::Message,
PrivacyPreservingCircuitOutput,
)> {
let mut accepted = Vec::new();
let mut seen_nullifiers: Vec<Nullifier> = Vec::new();
let mut seen_commitments: Vec<Commitment> = Vec::new();
let mut seen_updated_account_ids: Vec<AccountId> = Vec::new();
for (tx, pre_states) in input_txs.into_iter().zip(public_pre_states) {
let message = to_aggregator_message(tx.message());
let circuit_output = message.clone().into_circuit_output(pre_states);
let updated_account_ids: Vec<AccountId> = circuit_output
.public_pre_states
.iter()
.zip(&circuit_output.public_post_states)
.filter(|(pre_state, post_state)| pre_state.account != **post_state)
.map(|(pre_state, _)| pre_state.account_id)
.collect();
let has_duplicate_nullifier = circuit_output
.new_nullifiers
.iter()
.any(|(nullifier, _)| seen_nullifiers.contains(nullifier));
let has_duplicate_commitment = circuit_output
.new_commitments
.iter()
.any(|commitment| seen_commitments.contains(commitment));
let has_duplicate_account_update = updated_account_ids
.iter()
.any(|account_id| seen_updated_account_ids.contains(account_id));
if has_duplicate_nullifier || has_duplicate_commitment || has_duplicate_account_update {
continue;
}
seen_nullifiers.extend(circuit_output.new_nullifiers.iter().map(|(n, _)| *n));
seen_commitments.extend(circuit_output.new_commitments.iter().cloned());
seen_updated_account_ids.extend(updated_account_ids);
accepted.push((tx, message, circuit_output));
}
accepted
}
/// Runs the sequencer aggregator guest over `input_txs`.
///
/// `public_pre_states[i]` must be the resolved `AccountWithMetadata` for each of
/// `input_txs[i].message().public_account_ids`, in the same order (see
/// [`select_aggregatable_transactions`]).
///
/// `input_txs` is first filtered down via [`select_aggregatable_transactions`]; only the
/// surviving transactions are proven against. For each surviving transaction, the host adds
/// its proof as a recursive assumption against the `PrivacyPreservingCircuitOutput` it
/// commits to. The guest redoes the same reconstruction from the mirrored message and
/// `public_pre_states`, verifies each proof via `env::verify`, checks nullifier/commitment/
/// account-update uniqueness and `block_id`/`timestamp` validity windows across the batch,
/// and commits the verified messages alongside `block_id`/`timestamp` as the journal.
///
/// `segment_limit_po2`, if set, caps each segment to `2^segment_limit_po2` cycles (see
/// [`risc0_zkvm::ExecutorEnvBuilder::segment_limit_po2`]).
///
/// Returns the aggregated journal alongside the Borsh-encoded aggregation proof
/// (`prove_info.receipt.inner`).
pub fn aggregate(
input_txs: Vec<PrivacyPreservingTransaction>,
public_pre_states: Vec<Vec<AccountWithMetadata>>,
block_id: BlockId,
timestamp: Timestamp,
segment_limit_po2: Option<u32>,
elf: &[u8],
) -> Result<(SequencerAggregatorOutput, Vec<u8>), LeeError> {
crate::ensure!(
input_txs.len() == public_pre_states.len(),
LeeError::InvalidInput("transactions and public pre-states length mismatch".into())
);
let accepted = select_aggregatable_transactions(input_txs, public_pre_states);
let mut env_builder = ExecutorEnv::builder();
if let Some(po2) = segment_limit_po2 {
env_builder.segment_limit_po2(po2);
}
let mut messages = Vec::with_capacity(accepted.len());
let mut verified_pre_states = Vec::with_capacity(accepted.len());
for (tx, message, circuit_output) in accepted {
let proof_bytes = tx.witness_set().proof().clone().into_inner();
let inner: InnerReceipt = borsh::from_slice(&proof_bytes)?;
env_builder.add_assumption(Receipt::new(inner, circuit_output.to_bytes()));
messages.push(message);
verified_pre_states.push(circuit_output.public_pre_states);
}
env_builder
.write(&PRIVACY_PRESERVING_CIRCUIT_ID)
.map_err(|e| LeeError::ProgramWriteInputFailed(e.to_string()))?;
env_builder
.write(&block_id)
.map_err(|e| LeeError::ProgramWriteInputFailed(e.to_string()))?;
env_builder
.write(&timestamp)
.map_err(|e| LeeError::ProgramWriteInputFailed(e.to_string()))?;
env_builder
.write(&messages)
.map_err(|e| LeeError::ProgramWriteInputFailed(e.to_string()))?;
env_builder
.write(&verified_pre_states)
.map_err(|e| LeeError::ProgramWriteInputFailed(e.to_string()))?;
let env = env_builder.build().unwrap();
let prove_info = default_prover()
.prove_with_opts(env, elf, &ProverOpts::succinct())
.map_err(|e| LeeError::CircuitProvingError(e.to_string()))?;
let output = prove_info
.receipt
.journal
.decode()
.map_err(|e| LeeError::ProgramExecutionFailed(e.to_string()))?;
let proof = borsh::to_vec(&prove_info.receipt.inner)?;
Ok((output, proof))
}
#[cfg(test)]
pub(crate) mod tests {
#![expect(
clippy::arithmetic_side_effects,
reason = "test transaction generator — deterministic index arithmetic and balance math"
)]
#![expect(
clippy::print_stderr,
reason = "test transaction cache reports hits/misses for local iteration"
)]
use authenticated_transfer_core::Instruction;
use lee_core::{
InputAccountIdentity, NullifierPublicKey, PrivacyPreservingCircuitOutput,
SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce},
encryption::ViewingPublicKey,
};
use risc0_zkvm::serde::from_slice;
use test_program_methods::SEQUENCER_AGGREGATOR_ELF;
use super::*;
use crate::{
PrivateKey, PublicKey, V03State, execute_and_prove,
privacy_preserving_transaction::{
circuit::ProgramWithDependencies, message::Message, witness_set::WitnessSet,
},
program::Program,
};
/// Block id and timestamp the generated transactions' validity windows and nonces are
/// proven/checked against.
const TIMESTAMP: u64 = 1_700_000_000;
/// Derives a deterministic, valid `PrivateKey` for transaction index `i`.
///
/// Only `seed[0]` and `seed[1]` vary; the remaining bytes are fixed at `50`, which keeps
/// the resulting 256-bit big-endian value comfortably below the secp256k1 curve order
/// for any `seed[0]`/`seed[1]`, so the key is always valid.
fn sender_signing_key(i: usize) -> PrivateKey {
let (lo, hi) = index_bytes(i);
let mut seed = [50_u8; 32];
seed[0] = lo;
seed[1] = hi;
PrivateKey::try_new(seed).expect("deterministic seed should be a valid private key")
}
/// Splits `i` into its low and high byte, for use as deterministic seed variation.
fn index_bytes(i: usize) -> (u8, u8) {
let lo = u8::try_from(i & 0xFF).expect("masked value fits in u8");
let hi = u8::try_from((i >> 8) & 0xFF).expect("masked value fits in u8");
(lo, hi)
}
/// Generates `count` independent "public sender -> private recipient" transfers, each
/// proven through the privacy-preserving execution circuit, plus the genesis
/// `V03State` their sender accounts were proven against.
pub(crate) fn generate_test_transactions(
count: usize,
) -> (
V03State,
Vec<PrivacyPreservingTransaction>,
Vec<PrivacyPreservingCircuitOutput>,
) {
let program = Program::authenticated_transfer_program();
let mut transactions = Vec::with_capacity(count);
let mut circuit_outputs = Vec::with_capacity(count);
let mut genesis_accounts = Vec::with_capacity(count);
for i in 0..count {
let (lo, hi) = index_bytes(i);
// 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 index.
let mut msg = [44_u8; 32];
msg[0] = lo;
msg[1] = hi;
let amount: u128 = 100;
let vpk = ViewingPublicKey::from_seed(&d, &z);
// Recipient: fresh private account derived from this transaction's index.
let mut nsk = [41_u8; 32];
nsk[0] = lo;
nsk[1] = hi;
let npk = NullifierPublicKey::from(&nsk);
let (ssk, epk) = SharedSecretKey::encapsulate_deterministic(&vpk, &msg, 0);
// Sender: public account whose id is derived from a real signing key, so the
// transaction's signature matches `message.public_account_ids`.
let signing_key = sender_signing_key(i);
let sender_account_id =
AccountId::from(&PublicKey::new_from_private_key(&signing_key));
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: amount + 10,
..Account::default()
},
true,
sender_account_id,
);
let recipient = AccountWithMetadata::new(
Account::default(),
false,
AccountId::for_regular_private_account(&npk, 0),
);
let instruction = Program::serialize_instruction(Instruction::Transfer { amount })
.expect("serialize instruction");
let (output, proof) = execute_and_prove(
vec![sender, recipient],
instruction,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
npk,
ssk,
identifier: 0,
},
],
&ProgramWithDependencies::from(program.clone()),
)
.expect("execute_and_prove");
// `output` is consumed by `try_from_circuit_output` below; keep its bytes so
// we can hand the same circuit output to `aggregate` alongside the tx.
let output_bytes = output.to_bytes();
let message = Message::try_from_circuit_output(
vec![sender_account_id],
vec![Nonce(0)],
vec![(npk, vpk, epk)],
output,
)
.expect("build message from circuit output");
let witness_set = WitnessSet::for_message(&message, proof, &[&signing_key]);
transactions.push(PrivacyPreservingTransaction::new(message, witness_set));
let output_words: &[u32] = bytemuck::cast_slice(&output_bytes);
circuit_outputs
.push(from_slice(output_words).expect("decode circuit output bytes"));
genesis_accounts.push((sender_account_id, amount + 10));
}
let state = V03State::new_with_genesis_accounts(&genesis_accounts, vec![], TIMESTAMP);
(state, transactions, circuit_outputs)
}
/// Maximum number of transactions [`bench_sequencer_aggregator`] loads/generates.
///
/// `AGGREGATOR_COUNT` truncates down from this, so bumping it regenerates
/// [`BENCH_TRANSACTIONS_CACHE_PATH`] (a one-time cost — see
/// [`load_or_generate_test_transactions`]).
const BENCH_MAX_TRANSACTIONS: usize = 8;
/// Path to a Borsh-encoded cache of [`generate_test_transactions`]'s output, used by
/// [`bench_sequencer_aggregator`].
///
/// TEMPORARY: avoids re-running `execute_and_prove` on every test invocation while
/// this circuit is under active development. Remove this caching (and the cache
/// file) once the host/guest design has stabilized.
const BENCH_TRANSACTIONS_CACHE_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../target/sequencer_aggregator_bench_transactions.bin"
);
/// Like [`generate_test_transactions`], but reuses a Borsh-encoded cache from disk
/// when one matching `count` exists, instead of re-proving every transaction.
///
/// `PrivacyPreservingCircuitOutput` doesn't derive Borsh, so it's cached via its
/// risc0-serde `to_bytes()` representation (one `Vec<u8>` per output) and decoded
/// back with `from_slice` on load, same as [`generate_test_transactions`] does for
/// the freshly-generated case.
pub(crate) fn load_or_generate_test_transactions(
count: usize,
cache_path: &str,
) -> (
V03State,
Vec<PrivacyPreservingTransaction>,
Vec<PrivacyPreservingCircuitOutput>,
) {
if let Ok(bytes) = std::fs::read(cache_path) {
if let Ok((state, transactions, output_bytes)) =
borsh::from_slice::<(V03State, Vec<PrivacyPreservingTransaction>, Vec<Vec<u8>>)>(
&bytes,
)
{
if transactions.len() == count {
eprintln!(
"[sequencer_aggregator] loaded {count} cached test transaction(s) from {cache_path}"
);
let circuit_outputs = output_bytes
.iter()
.map(|bytes| {
let words: &[u32] = bytemuck::cast_slice(bytes);
from_slice(words).expect("decode cached circuit output")
})
.collect();
return (state, transactions, circuit_outputs);
}
}
}
eprintln!(
"[sequencer_aggregator] generating {count} test transaction(s) (cache miss at {cache_path})"
);
let (state, transactions, circuit_outputs) = generate_test_transactions(count);
let output_bytes: Vec<Vec<u8>> = circuit_outputs
.iter()
.map(PrivacyPreservingCircuitOutput::to_bytes)
.collect();
let bytes = borsh::to_vec(&(&state, &transactions, &output_bytes))
.expect("serialize test transactions");
std::fs::write(cache_path, bytes).expect("write test transactions cache");
(state, transactions, circuit_outputs)
}
/// Benchmark: aggregate `AGGREGATOR_COUNT` (default: [`BENCH_MAX_TRANSACTIONS`]) PPE
/// transactions from the cached test transaction set.
///
/// The cache is generated once for [`BENCH_MAX_TRANSACTIONS`] transactions; lower
/// `AGGREGATOR_COUNT` values truncate that set rather than regenerating it, so repeated
/// runs across different counts don't re-prove the underlying PPE transactions.
///
/// Control via env vars:
/// - `AGGREGATOR_COUNT`: number of transactions to aggregate (default:
/// [`BENCH_MAX_TRANSACTIONS`]).
/// - `PPE_SEGMENT_LIMIT_PO2`: segment size limit (`log2` of cycles per segment) passed to
/// the executor.
///
/// Output line (captured by `bench_sequencer_aggregator_cuda.sh`):
/// `[lee::analytics] sequencer_aggregator n=… proving_ms=… proof_size_bytes=…`.
#[test]
fn bench_sequencer_aggregator() {
let (_state, mut transactions, mut circuit_outputs) = load_or_generate_test_transactions(
BENCH_MAX_TRANSACTIONS,
BENCH_TRANSACTIONS_CACHE_PATH,
);
if let Ok(s) = std::env::var("AGGREGATOR_COUNT") {
let count: usize = s.parse().expect("AGGREGATOR_COUNT must be a number");
transactions.truncate(count);
circuit_outputs.truncate(count);
}
let public_pre_states: Vec<Vec<AccountWithMetadata>> = circuit_outputs
.into_iter()
.map(|output| output.public_pre_states)
.collect();
let segment_limit_po2: Option<u32> = std::env::var("PPE_SEGMENT_LIMIT_PO2")
.ok()
.map(|s| s.parse().expect("PPE_SEGMENT_LIMIT_PO2 must be a number"));
let n = transactions.len();
let t0 = std::time::Instant::now();
let (_output, proof) = aggregate(
transactions,
public_pre_states,
lee_core::GENESIS_BLOCK_ID,
TIMESTAMP,
segment_limit_po2,
SEQUENCER_AGGREGATOR_ELF,
)
.expect("aggregate should succeed");
let proving_ms = t0.elapsed().as_millis();
let proof_size = proof.len();
eprintln!(
"[lee::analytics] sequencer_aggregator n={n} proving_ms={proving_ms} proof_size_bytes={proof_size}"
);
}
}

View File

@ -1,80 +0,0 @@
//! Aggregator Circuit.
//!
//! Verifies N privacy-preserving circuit proofs and enforces:
//! - Intra-batch uniqueness of nullifiers and commitments.
//! - No public account is updated by more than one transaction in the batch.
//!
//! The full `PrivacyPreservingCircuitOutput` for each transaction is committed to the
//! journal so observers can perform state-dependent checks independently.
use std::convert::Infallible;
use lee_core::{AggregatorCircuitInput, AggregatorCircuitOutput};
use risc0_zkvm::{guest::env, serde::to_vec};
fn main() {
let AggregatorCircuitInput {
privacy_preserving_circuit_id,
block_id,
timestamp,
circuit_outputs,
} = env::read();
for output in &circuit_outputs {
let output_words =
to_vec(output).expect("PrivacyPreservingCircuitOutput serialization should not fail");
env::verify(privacy_preserving_circuit_id, &output_words)
.unwrap_or_else(|_: Infallible| unreachable!("Infallible error is never constructed"));
}
// TEMPORARY: dedup checks (items 1-3) disabled for debugging — isolating proof
// verification (item 4) to narrow down a bench_aggregator failure at n=2.
/*
// 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<Nullifier> = Vec::new();
for output in &circuit_outputs {
for (nullifier, _) in &output.new_nullifiers {
assert!(
!seen_nullifiers.contains(nullifier),
"Duplicate nullifier across transactions in batch"
);
seen_nullifiers.push(*nullifier);
}
}
let mut seen_commitments: Vec<Commitment> = Vec::new();
for output in &circuit_outputs {
for commitment in &output.new_commitments {
assert!(
!seen_commitments.contains(commitment),
"Duplicate commitment across transactions in batch"
);
seen_commitments.push(commitment.clone());
}
}
let mut seen_updated_account_ids: Vec<AccountId> = 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.contains(&pre_state.account_id),
"Public account updated by multiple transactions in batch"
);
seen_updated_account_ids.push(pre_state.account_id);
}
}
}
*/
env::commit(&AggregatorCircuitOutput {
block_id,
timestamp,
circuit_outputs,
});
}

View File

@ -1,85 +0,0 @@
//! Aggregator Circuit (Strict).
//!
//! 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::convert::Infallible;
use lee_core::{
AggregatorCircuitInput, AggregatorCircuitOutput, Commitment, Nullifier, account::AccountId,
};
use risc0_zkvm::{guest::env, serde::to_vec};
fn main() {
let AggregatorCircuitInput {
privacy_preserving_circuit_id,
block_id,
timestamp,
circuit_outputs,
} = env::read();
for output in &circuit_outputs {
let output_words =
to_vec(output).expect("PrivacyPreservingCircuitOutput serialization should not fail");
env::verify(privacy_preserving_circuit_id, &output_words)
.unwrap_or_else(|_: Infallible| unreachable!("Infallible error is never constructed"));
}
// 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<Nullifier> = Vec::new();
for output in &circuit_outputs {
for (nullifier, _) in &output.new_nullifiers {
assert!(
!seen_nullifiers.contains(nullifier),
"Duplicate nullifier across transactions in batch"
);
seen_nullifiers.push(*nullifier);
}
}
let mut seen_commitments: Vec<Commitment> = Vec::new();
for output in &circuit_outputs {
for commitment in &output.new_commitments {
assert!(
!seen_commitments.contains(commitment),
"Duplicate commitment across transactions in batch"
);
seen_commitments.push(commitment.clone());
}
}
for output in &circuit_outputs {
assert!(
output.block_validity_window.is_valid_for(block_id),
"Transaction block validity window does not include the block id"
);
assert!(
output.timestamp_validity_window.is_valid_for(timestamp),
"Transaction timestamp validity window does not include the timestamp"
);
}
let mut seen_updated_account_ids: Vec<AccountId> = 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.contains(&pre_state.account_id),
"Public account updated by multiple transactions in batch"
);
seen_updated_account_ids.push(pre_state.account_id);
}
}
}
env::commit(&AggregatorCircuitOutput {
block_id,
timestamp,
circuit_outputs,
});
}

View File

@ -15,7 +15,7 @@ use risc0_zkvm::{guest::env, serde::to_vec};
/// 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
/// `to_vec()` for `env::verify`. This replaced reading
/// each journal as a raw `env::read::<Vec<u8>>()`: risc0's default serde deserializes
/// `Vec<u8>` 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

View File

@ -0,0 +1,106 @@
use std::convert::Infallible;
use lee_core::{
BlockId, Commitment, Nullifier, SequencerAggregatorOutput, Timestamp,
account::{AccountId, AccountWithMetadata},
message::Message,
};
use risc0_zkvm::{guest::env, serde::to_vec};
/// Sequencer aggregator circuit.
///
/// The host writes:
/// 1. The PPE circuit image ID (`[u32; 8]`)
/// 2. `block_id: BlockId`
/// 3. `timestamp: Timestamp`
/// 4. `Vec<Message>` — the `lee_core`-resident mirror of each transaction's `Message`
/// 5. `Vec<Vec<AccountWithMetadata>>` — `public_pre_states` for each message's
/// `public_account_ids`, in the same order
///
/// It also adds each transaction's PPE receipt as an assumption before running this guest.
///
/// Checks:
/// 1. Nullifiers and commitments are unique across all messages in the batch.
/// 2. Each public account is updated by at most one transaction in the batch.
/// 3. `block_id`/`timestamp` fall within each message's validity window.
/// 4. Each message's PPE proof verifies (`Message::into_circuit_output` + `env::verify`).
/// The host filters out transactions that would fail any of these checks before
/// building this input, so failures here should never occur.
///
/// Journal: [`SequencerAggregatorOutput`] — `block_id`, `timestamp`, and the verified
/// messages.
fn main() {
let ppe_image_id: [u32; 8] = env::read();
let block_id: BlockId = env::read();
let timestamp: Timestamp = env::read();
let messages: Vec<Message> = env::read();
let public_pre_states: Vec<Vec<AccountWithMetadata>> = env::read();
assert_eq!(
messages.len(),
public_pre_states.len(),
"sequencer_aggregator: messages and public_pre_states length mismatch"
);
// 1. Nullifiers and commitments are unique across all messages in the batch.
let mut seen_nullifiers: Vec<Nullifier> = Vec::new();
let mut seen_commitments: Vec<Commitment> = Vec::new();
for message in &messages {
for (nullifier, _) in &message.new_nullifiers {
assert!(
!seen_nullifiers.contains(nullifier),
"Duplicate nullifier across transactions in batch"
);
seen_nullifiers.push(*nullifier);
}
for commitment in &message.new_commitments {
assert!(
!seen_commitments.contains(commitment),
"Duplicate commitment across transactions in batch"
);
seen_commitments.push(commitment.clone());
}
}
// 2. Each public account is updated by at most one transaction in the batch.
let mut seen_updated_account_ids: Vec<AccountId> = Vec::new();
for (message, pre_states) in messages.iter().zip(&public_pre_states) {
for (pre_state, post_state) in pre_states.iter().zip(&message.public_post_states) {
if pre_state.account != *post_state {
assert!(
!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);
}
}
}
// 3. `block_id`/`timestamp` fall within each message's validity window.
for message in &messages {
assert!(
message.block_validity_window.is_valid_for(block_id),
"Transaction block validity window does not include the block id"
);
assert!(
message.timestamp_validity_window.is_valid_for(timestamp),
"Transaction timestamp validity window does not include the timestamp"
);
}
let output = SequencerAggregatorOutput {
block_id,
timestamp,
messages,
};
env::commit(&output);
// 4. Each message's PPE proof verifies.
for (message, pre_states) in output.messages.into_iter().zip(public_pre_states) {
let circuit_output = message.into_circuit_output(pre_states);
let output_words = to_vec(&circuit_output)
.expect("PrivacyPreservingCircuitOutput serialization should not fail");
env::verify(ppe_image_id, &output_words)
.unwrap_or_else(|_: Infallible| unreachable!("Infallible error is never constructed"));
}
}

View File

@ -13,7 +13,6 @@ default = []
prove = ["lee/prove", "risc0-zkvm/prove"]
cuda = ["lee/cuda", "risc0-zkvm/cuda"]
ppe = ["prove"]
aggregator = ["ppe"]
[dependencies]
lee = { workspace = true }

View File

@ -1,113 +0,0 @@
//! Aggregator circuit bench module.
//!
//! Measures wall-clock time for batching N privacy-preserving circuit proofs into a
//! single aggregated proof, using both the core and strict aggregator variants.
//!
//! Reported metrics per (N, variant) pair:
//! - `pp_prove_ms`: time to generate the N pp-circuit proofs (context for total cost)
//! - `agg_prove_ms`: time to run `aggregate()` — the sequencer's batch proving step
//! - `agg_proof_bytes`: borsh-serialized `InnerReceipt` of the aggregated proof
//! - `pp_proof_bytes_per_tx`: same metric for one pp-proof, for size comparison
//!
//! Requires `--features aggregator` and a full build (aggregator ELFs must exist in
//! `artifacts/program_methods/`).
#![allow(
dead_code,
reason = "Stubs are used when the `aggregator` feature is disabled."
)]
use serde::Serialize;
#[cfg(feature = "aggregator")]
mod agg_impl;
#[derive(Debug, Serialize, Clone)]
pub struct AggregatorBenchResult {
pub label: String,
pub n_txs: usize,
pub strict: bool,
/// Total wall-clock time to generate all N pp-circuit proofs (ms).
pub pp_prove_ms: Option<f64>,
/// Wall-clock time for the `aggregate()` call alone (ms).
pub agg_prove_ms: Option<f64>,
/// borsh-serialized `InnerReceipt` length of the aggregated proof (bytes).
pub agg_proof_bytes: Option<usize>,
/// borsh-serialized `InnerReceipt` length of one pp-proof, for comparison (bytes).
pub pp_proof_bytes_per_tx: Option<usize>,
pub error: Option<String>,
}
#[cfg(not(feature = "aggregator"))]
#[must_use]
pub const fn run_all() -> Vec<AggregatorBenchResult> {
Vec::new()
}
#[cfg(feature = "aggregator")]
#[must_use]
pub fn run_all() -> Vec<AggregatorBenchResult> {
let mut results = Vec::new();
for n_txs in [1_usize, 3, 5] {
for strict in [false, true] {
let variant = if strict { "strict" } else { "core" };
eprintln!("aggregator: {variant} n={n_txs}");
results.push(agg_impl::run(n_txs, strict));
}
}
results
}
pub fn print_table(results: &[AggregatorBenchResult]) {
if results.is_empty() {
return;
}
let lw = results
.iter()
.map(|r| r.label.len())
.max()
.unwrap_or(0)
.max("label".len());
println!(
"\n{:<lw$} {:>5} {:>22} {:>22} {:>12} {:>12} {}",
"label",
"n_txs",
"pp_prove_ms (s)",
"agg_prove_ms (s)",
"agg_bytes",
"pp_bytes/tx",
"error",
lw = lw,
);
println!("{}", "-".repeat(lw + 85));
for r in results {
let pp = fmt_ms(r.pp_prove_ms);
let ap = fmt_ms(r.agg_prove_ms);
let ab = r
.agg_proof_bytes
.map_or_else(|| "-".to_owned(), |n| n.to_string());
let pb = r
.pp_proof_bytes_per_tx
.map_or_else(|| "-".to_owned(), |n| n.to_string());
let e = r.error.as_deref().unwrap_or("");
println!(
"{:<lw$} {:>5} {:>22} {:>22} {:>12} {:>12} {}",
r.label,
r.n_txs,
pp,
ap,
ab,
pb,
e,
lw = lw,
);
}
}
fn fmt_ms(ms: Option<f64>) -> String {
ms.map_or_else(
|| "-".to_owned(),
|v| format!("{v:.1} ({:.1}s)", v / 1_000.0),
)
}

View File

@ -1,218 +0,0 @@
//! Feature-gated implementation of aggregator circuit benches.
//!
//! ELFs are loaded at runtime from `artifacts/program_methods/` so this module
//! compiles even before a full RISC0 build. If the ELFs are not present, each
//! bench run reports an error rather than panicking.
use std::{path::PathBuf, time::Instant};
use authenticated_transfer_core::Instruction;
use lee::{
PrivacyPreservingTransaction, PrivateKey, PublicKey, V03State,
aggregator_circuit::aggregate,
execute_and_prove,
privacy_preserving_transaction::{
circuit::ProgramWithDependencies, message::Message, witness_set::WitnessSet,
},
program::Program,
program_methods::{AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID},
};
use lee_core::{
BlockId, InputAccountIdentity, NullifierPublicKey, SharedSecretKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata, Nonce},
encryption::ViewingPublicKey,
};
use risc0_zkvm::serde::to_vec;
use super::AggregatorBenchResult;
/// Loads an aggregator ELF from `artifacts/program_methods/{name}.bin` at runtime.
fn load_aggregator_elf(name: &str) -> anyhow::Result<Vec<u8>> {
let artifacts = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("artifacts/program_methods");
let path = artifacts.join(format!("{name}.bin"));
std::fs::read(&path).map_err(|e| {
anyhow::anyhow!(
"aggregator ELF not found at {}: {e}\n\
Run a full RISC0 build (without RISC0_SKIP_BUILD=1) to generate it.",
path.display()
)
})
}
/// Derives a deterministic, valid `PrivateKey` for sender `tag`.
///
/// Only `seed[0]` varies; the remaining bytes are fixed at `50`, which keeps the
/// resulting 256-bit big-endian value comfortably below the secp256k1 curve order for
/// any `tag`, so the key is always valid.
fn sender_signing_key(tag: u8) -> PrivateKey {
let mut seed = [50_u8; 32];
seed[0] = tag;
PrivateKey::try_new(seed).expect("deterministic seed should be a valid private key")
}
/// Generates a public-to-private (shielded) auth-transfer pp-transaction.
///
/// The sender is a public account whose id is derived from a real signing key, so the
/// resulting transaction's signature matches its `message.public_account_ids`; the
/// recipient is a fresh private account derived from `tag`. Distinct tags yield distinct
/// `npk` values → distinct commitments and nullifiers, so any number of these
/// transactions can be safely aggregated in one batch.
fn prove_shielded_transfer(tag: u8) -> anyhow::Result<(AccountId, PrivacyPreservingTransaction)> {
let nsk: [u8; 32] = [tag; 32];
let d: [u8; 32] = [tag.wrapping_add(64); 32];
let z: [u8; 32] = [tag.wrapping_add(128); 32];
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_seed(&d, &z);
let (ssk, epk) = SharedSecretKey::encapsulate(&vpk);
let recipient_account_id = AccountId::for_regular_private_account(&npk, 0);
let signing_key = sender_signing_key(tag);
let sender_account_id = AccountId::from(&PublicKey::new_from_private_key(&signing_key));
let program = Program::new(AUTHENTICATED_TRANSFER_ELF.to_vec())?;
let pwd = ProgramWithDependencies::from(program);
// Public sender with sufficient balance; unique account ID per tag so the
// strict aggregator's public-account-uniqueness check passes.
let sender = AccountWithMetadata {
account: Account {
program_owner: AUTHENTICATED_TRANSFER_ID,
balance: 1_000_000,
..Account::default()
},
is_authorized: true,
account_id: sender_account_id,
};
// Fresh private recipient account (zero balance, not yet authorized).
let recipient = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: recipient_account_id,
};
let instruction_data = to_vec(&Instruction::Transfer { amount: 1_000 })?;
let identities = vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
npk,
ssk,
identifier: 0,
},
];
let (output, proof) = execute_and_prove(vec![sender, recipient], instruction_data, identities, &pwd)?;
let message = Message::try_from_circuit_output(
vec![sender_account_id],
vec![Nonce(0)],
vec![(npk, vpk, epk)],
output,
)?;
let witness_set = WitnessSet::for_message(&message, proof, &[&signing_key]);
Ok((
sender_account_id,
PrivacyPreservingTransaction::new(message, witness_set),
))
}
pub fn run(n_txs: usize, strict: bool) -> AggregatorBenchResult {
let elf_name = if strict {
"aggregator_circuit_strict"
} else {
"aggregator_circuit"
};
let label = format!(
"aggregator_{} n={n_txs}",
if strict { "strict" } else { "core" }
);
let elf = match load_aggregator_elf(elf_name) {
Ok(bytes) => bytes,
Err(e) => {
return AggregatorBenchResult {
label,
n_txs,
strict,
pp_prove_ms: None,
agg_prove_ms: None,
agg_proof_bytes: None,
pp_proof_bytes_per_tx: None,
error: Some(e.to_string()),
};
}
};
// Generate N pp-transactions with distinct private recipients (tags 1..=N).
let pp_started = Instant::now();
let txs: Result<Vec<_>, anyhow::Error> = (0..n_txs)
.map(|i| prove_shielded_transfer(u8::try_from(i + 1).unwrap_or(u8::MAX)))
.collect();
let pp_prove_ms = pp_started.elapsed().as_secs_f64() * 1_000.0;
let txs = match txs {
Ok(t) => t,
Err(e) => {
return AggregatorBenchResult {
label,
n_txs,
strict,
pp_prove_ms: Some(pp_prove_ms),
agg_prove_ms: None,
agg_proof_bytes: None,
pp_proof_bytes_per_tx: None,
error: Some(e.to_string()),
};
}
};
// Capture per-tx proof size before the vec is consumed by aggregate().
let pp_proof_bytes_per_tx = txs
.first()
.map(|(_, tx)| tx.witness_set().proof().clone().into_inner().len());
let block_id: BlockId = 1;
let timestamp = Timestamp::from(1_700_000_000_u64);
// Genesis state containing each sender's public account, matching the balance used
// when proving its transaction.
let genesis_accounts: Vec<(AccountId, u128)> = txs
.iter()
.map(|(account_id, _)| (*account_id, 1_000_000))
.collect();
let state = V03State::new_with_genesis_accounts(&genesis_accounts, vec![], timestamp);
let transactions: Vec<PrivacyPreservingTransaction> =
txs.into_iter().map(|(_, tx)| tx).collect();
let agg_started = Instant::now();
let result = aggregate(block_id, timestamp, transactions, &state, &elf, None);
let agg_prove_ms = agg_started.elapsed().as_secs_f64() * 1_000.0;
match result {
Ok((_output, agg_proof)) => AggregatorBenchResult {
label,
n_txs,
strict,
pp_prove_ms: Some(pp_prove_ms),
agg_prove_ms: Some(agg_prove_ms),
agg_proof_bytes: Some(agg_proof.into_inner().len()),
pp_proof_bytes_per_tx,
error: None,
},
Err(e) => AggregatorBenchResult {
label,
n_txs,
strict,
pp_prove_ms: Some(pp_prove_ms),
agg_prove_ms: Some(agg_prove_ms),
agg_proof_bytes: None,
pp_proof_bytes_per_tx,
error: Some(e.to_string()),
},
}
}

View File

@ -18,13 +18,12 @@
)
)]
#![cfg_attr(
any(feature = "ppe", feature = "aggregator"),
feature = "ppe",
expect(
clippy::print_stderr,
reason = "PPE/aggregator bench: eprintln progress messages"
reason = "PPE bench: eprintln progress messages"
)
)]
pub mod aggregator;
pub mod ppe;
pub mod stats;

View File

@ -27,7 +27,7 @@ use clock_core::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
ClockAccountData,
};
use cycle_bench::{aggregator, ppe, stats::Stats};
use cycle_bench::{ppe, stats::Stats};
use lee::program_methods::{
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, TOKEN_ELF,
@ -55,12 +55,6 @@ struct Cli {
#[arg(long)]
ppe: bool,
/// Also run aggregator circuit benches: batch N=1,3,5 pp-proofs through both the
/// core and strict aggregator circuits. Reports pp-prove time, agg-prove time, and
/// proof sizes. Requires --features aggregator at build time and a full RISC0 build.
#[arg(long)]
aggregator: bool,
/// Iterations for executor wall-time sampling per case. First iter is
/// discarded as warmup, remaining N feed the stats.
#[arg(long, default_value_t = 5)]
@ -521,25 +515,6 @@ fn main() -> Result<()> {
ppe::print_table(&ppe_results);
}
#[cfg(feature = "aggregator")]
let agg_results = if cli.aggregator {
aggregator::run_all()
} else {
Vec::new()
};
#[cfg(not(feature = "aggregator"))]
let agg_results: Vec<aggregator::AggregatorBenchResult> = {
if cli.aggregator {
eprintln!(
"cycle_bench: --aggregator requires --features aggregator at build time. Ignoring."
);
}
Vec::new()
};
if !agg_results.is_empty() {
aggregator::print_table(&agg_results);
}
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
@ -551,7 +526,6 @@ fn main() -> Result<()> {
let combined = serde_json::json!({
"standalone": results,
"ppe": ppe_results,
"aggregator": agg_results,
});
std::fs::write(&out_path, serde_json::to_string_pretty(&combined)?)?;
println!("\nJSON written to {}", out_path.display());