mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-06-29 10:29:32 +00:00
cleaned up sequencer_aggregator circuit
This commit is contained in:
parent
038dba11f1
commit
70ceed0adc
Binary file not shown.
Binary file not shown.
@ -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"
|
||||
66
bench_sequencer_aggregator_cuda.sh
Executable file
66
bench_sequencer_aggregator_cuda.sh
Executable 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"
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
68
lee/state_machine/core/src/message.rs
Normal file
68
lee/state_machine/core/src/message.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
lee/state_machine/core/src/sequencer_aggregator_io.rs
Normal file
12
lee/state_machine/core/src/sequencer_aggregator_io.rs
Normal 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>,
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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| {
|
||||
|
||||
474
lee/state_machine/src/sequencer_aggregator.rs
Normal file
474
lee/state_machine/src/sequencer_aggregator.rs
Normal 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(×tamp)
|
||||
.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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
|
||||
106
test_program_methods/guest/src/bin/sequencer_aggregator.rs
Normal file
106
test_program_methods/guest/src/bin/sequencer_aggregator.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
@ -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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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());
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user