Sequencer aggregation

This commit is contained in:
Marvin Jones 2026-06-09 17:36:33 -04:00
parent 455e0a925e
commit 32813f606b
63 changed files with 808 additions and 10 deletions

1
Cargo.lock generated
View File

@ -4979,6 +4979,7 @@ dependencies = [
"authenticated_transfer_core",
"borsh",
"bridge_core",
"bytemuck",
"clock_core",
"env_logger",
"faucet_core",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

74
bench_aggregator_cuda.sh Executable file
View File

@ -0,0 +1,74 @@
#!/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 -- --output ppe_fixtures.bin
#
# Usage:
# ./bench_aggregator_cuda.sh
#
# Environment:
# PPE_FIXTURES — path to fixture file (default: ppe_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_FIXTURES:-ppe_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 -- --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_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

@ -32,6 +32,7 @@ risc0-binfmt = "3.0.2"
[dev-dependencies]
lee_core = { workspace = true, features = ["test_utils"] }
bytemuck.workspace = true
token_core.workspace = true
authenticated_transfer_core.workspace = true
test_program_methods.workspace = true

View File

@ -0,0 +1,35 @@
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

@ -29,10 +29,9 @@ pub const DUMMY_COMMITMENT_HASH: [u8; 32] = [
129, 241, 118, 39, 41, 253, 141, 171, 184, 71, 8, 41,
];
#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
#[cfg_attr(
any(feature = "host", test),
derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)
#[derive(
Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, BorshSerialize,
BorshDeserialize,
)]
pub struct Commitment(pub(super) [u8; 32]);

View File

@ -3,6 +3,7 @@
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,
};
@ -15,6 +16,7 @@ pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKe
pub use program::PrivateAccountKind;
pub mod account;
mod aggregator_circuit_io;
mod circuit_io;
mod commitment;
mod encoding;

View File

@ -65,10 +65,9 @@ impl From<&NullifierSecretKey> for NullifierPublicKey {
pub type NullifierSecretKey = [u8; 32];
#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
#[cfg_attr(
any(feature = "host", test),
derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, BorshSerialize,
BorshDeserialize,
)]
pub struct Nullifier(pub(super) [u8; 32]);

View File

@ -0,0 +1,205 @@
//! 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, PrivacyPreservingCircuitOutput,
Timestamp,
};
use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover};
use crate::{
error::LeeError, privacy_preserving_transaction::circuit::Proof,
program_methods::PRIVACY_PRESERVING_CIRCUIT_ID,
};
/// 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()
}
/// Aggregates N privacy-preserving circuit proofs into a single proof.
///
/// `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,
proofs: Vec<(PrivacyPreservingCircuitOutput, Proof)>,
elf: &[u8],
segment_limit_po2: Option<u32>,
) -> Result<(AggregatorCircuitOutput, AggregatorProof), LeeError> {
run_aggregator(block_id, timestamp, proofs, elf, segment_limit_po2)
}
fn run_aggregator(
block_id: BlockId,
timestamp: Timestamp,
proofs: Vec<(PrivacyPreservingCircuitOutput, Proof)>,
elf: &[u8],
segment_limit_po2: Option<u32>,
) -> Result<(AggregatorCircuitOutput, AggregatorProof), LeeError> {
// TODO: add host-side pre-checks before invoking the prover (e.g. no duplicate
// nullifiers/commitments, validity windows, public account uniqueness) so obviously
// invalid batches are rejected cheaply without spending GPU time.
let mut env_builder = ExecutorEnv::builder();
if let Some(po2) = segment_limit_po2 {
env_builder.segment_limit_po2(po2);
}
let mut circuit_outputs = Vec::with_capacity(proofs.len());
for (circuit_output, proof) in proofs {
let inner = borsh::from_slice::<InnerReceipt>(&proof.into_inner())
.map_err(|e| LeeError::CircuitOutputDeserializationError(e.to_string()))?;
let receipt = Receipt::new(inner, circuit_output.to_bytes());
env_builder.add_assumption(receipt);
circuit_outputs.push(circuit_output);
}
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, PrivacyPreservingCircuitOutput, Timestamp};
use test_program_methods::PpeFixture;
use super::aggregate;
use crate::{
privacy_preserving_transaction::circuit::Proof,
program_methods::{
AGGREGATOR_CIRCUIT_ELF, AGGREGATOR_CIRCUIT_ID, AGGREGATOR_CIRCUIT_STRICT_ELF,
AGGREGATOR_CIRCUIT_STRICT_ID,
},
};
/// Benchmark: aggregate N pre-generated PPE proofs loaded from a fixture file.
///
/// Generate fixtures first:
/// cargo run --release -p ppe_test_data_gen -- --output ppe_fixtures.bin
///
/// Control via env vars:
/// PPE_FIXTURES — path to fixture file (default: ppe_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_FIXTURES").unwrap_or_else(|_| "ppe_fixtures.bin".to_owned());
let mut fixtures = PpeFixture::load_bundle(&path);
if fixtures.is_empty() {
return;
}
if let Ok(s) = std::env::var("AGGREGATOR_COUNT") {
let count: usize = s.parse().expect("AGGREGATOR_COUNT must be a number");
fixtures.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 proofs: Vec<(PrivacyPreservingCircuitOutput, Proof)> = fixtures
.iter()
.map(|f| {
let words: &[u32] = bytemuck::cast_slice(&f.output_bytes);
let output: PrivacyPreservingCircuitOutput =
risc0_zkvm::serde::from_slice(words).expect("fixture output_bytes invalid");
let proof = Proof::from_inner(f.proof_bytes.clone());
(output, proof)
})
.collect();
let block_id: BlockId = 1;
let timestamp = Timestamp::from(1_700_000_000_u64);
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 t0 = std::time::Instant::now();
let (_, agg_proof) =
aggregate(block_id, timestamp, proofs, 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();
eprintln!(
"[lee::analytics] aggregator n={} variant={variant} proving_ms={proving_ms} proof_size_bytes={proof_size}",
fixtures.len(),
);
let _ = circuit_id;
}
}

View File

@ -22,6 +22,7 @@ pub use state::{
};
pub use validated_state_diff::ValidatedStateDiff;
pub mod aggregator_circuit;
pub mod encoding;
pub mod error;
mod merkle_tree;

View File

@ -0,0 +1,69 @@
//! 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::{collections::HashSet, 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"));
}
let mut seen_nullifiers: HashSet<Nullifier> = HashSet::new();
for output in &circuit_outputs {
for (nullifier, _) in &output.new_nullifiers {
assert!(
seen_nullifiers.insert(*nullifier),
"Duplicate nullifier across transactions in batch"
);
}
}
let mut seen_commitments: HashSet<Commitment> = HashSet::new();
for output in &circuit_outputs {
for commitment in &output.new_commitments {
assert!(
seen_commitments.insert(commitment.clone()),
"Duplicate commitment across transactions in batch"
);
}
}
let mut seen_updated_account_ids: HashSet<AccountId> = HashSet::new();
for output in &circuit_outputs {
for (pre_state, post_state) in
output.public_pre_states.iter().zip(output.public_post_states.iter())
{
if pre_state.account != *post_state {
assert!(
seen_updated_account_ids.insert(pre_state.account_id),
"Public account updated by multiple transactions in batch"
);
}
}
}
env::commit(&AggregatorCircuitOutput {
block_id,
timestamp,
circuit_outputs,
});
}

View File

@ -0,0 +1,78 @@
//! 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::{collections::HashSet, 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"));
}
let mut seen_nullifiers: HashSet<Nullifier> = HashSet::new();
for output in &circuit_outputs {
for (nullifier, _) in &output.new_nullifiers {
assert!(
seen_nullifiers.insert(*nullifier),
"Duplicate nullifier across transactions in batch"
);
}
}
let mut seen_commitments: HashSet<Commitment> = HashSet::new();
for output in &circuit_outputs {
for commitment in &output.new_commitments {
assert!(
seen_commitments.insert(commitment.clone()),
"Duplicate commitment across transactions in batch"
);
}
}
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: HashSet<AccountId> = HashSet::new();
for output in &circuit_outputs {
for (pre_state, post_state) in
output.public_pre_states.iter().zip(output.public_post_states.iter())
{
if pre_state.account != *post_state {
assert!(
seen_updated_account_ids.insert(pre_state.account_id),
"Public account updated by multiple transactions in batch"
);
}
}
}
env::commit(&AggregatorCircuitOutput {
block_id,
timestamp,
circuit_outputs,
});
}

View File

@ -0,0 +1,12 @@
[package]
name = "sequencer_aggregation_circuit_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
borsh.workspace = true

View File

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

View File

@ -0,0 +1,110 @@
//! 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

@ -0,0 +1,177 @@
//! 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::{
aggregator_circuit::aggregate,
execute_and_prove,
privacy_preserving_transaction::circuit::{Proof, ProgramWithDependencies},
program::Program,
program_methods::{AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID},
};
use lee_core::{
BlockId, InputAccountIdentity, NullifierPublicKey, SharedSecretKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
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()
)
})
}
/// Generates a public-to-private (shielded) auth-transfer pp-proof.
///
/// The sender is a public account; 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 proofs can be safely aggregated in one batch.
fn prove_shielded_transfer(
tag: u8,
) -> anyhow::Result<(lee_core::PrivacyPreservingCircuitOutput, Proof)> {
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 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: AccountId::new([tag; 32]),
};
// 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 },
];
Ok(execute_and_prove(
vec![sender, recipient],
instruction_data,
identities,
&pwd,
)?)
}
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-proofs with distinct private recipients (tags 1..=N).
let pp_started = Instant::now();
let proofs: 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 proofs = match proofs {
Ok(p) => p,
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 = proofs.first().map(|(_, p)| p.clone().into_inner().len());
let block_id: BlockId = 1;
let timestamp = Timestamp::from(1_700_000_000_u64);
let agg_started = Instant::now();
let result = aggregate(block_id, timestamp, proofs, &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

@ -14,10 +14,17 @@
feature = "ppe",
expect(
clippy::arbitrary_source_item_ordering,
reason = "PPE module: re-export ordering trips strict lints"
)
)]
#![cfg_attr(
any(feature = "ppe", feature = "aggregator"),
expect(
clippy::print_stderr,
reason = "PPE module: re-export ordering and eprintln progress trip strict lints"
reason = "PPE/aggregator 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::{ppe, stats::Stats};
use cycle_bench::{aggregator, 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,6 +55,12 @@ 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)]
@ -515,6 +521,25 @@ 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("..")
@ -526,6 +551,7 @@ 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());