diff --git a/Cargo.lock b/Cargo.lock index dacda8d5..586c4b3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1930,10 +1930,12 @@ dependencies = [ "libc", "libssz", "libssz-types", + "rayon", "reqwest", "serde", "serde_json", "serde_yaml_ng", + "tempfile", "thiserror 2.0.18", "tikv-jemallocator", "tokio", diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 591490ca..206766b1 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -28,6 +28,9 @@ ethlambda-types.workspace = true ethlambda-rpc.workspace = true ethlambda-storage.workspace = true +# Parallel XMSS keygen for the real-crypto benchmark corpus. +rayon.workspace = true + libssz.workspace = true libssz-types.workspace = true @@ -56,6 +59,7 @@ libc.workspace = true # tests would otherwise wait out the real retry backoff. Dev-only, so the # feature never reaches the shipped binary. tokio = { workspace = true, features = ["test-util"] } +tempfile = "3" [build-dependencies] vergen-git2.workspace = true diff --git a/bin/ethlambda/src/benchmark/corpus.rs b/bin/ethlambda/src/benchmark/corpus.rs index bc007c54..1a97c211 100644 --- a/bin/ethlambda/src/benchmark/corpus.rs +++ b/bin/ethlambda/src/benchmark/corpus.rs @@ -1,48 +1,93 @@ -//! Synthetic benchmark corpus: deterministic validators, a genesis store, and -//! per-slot attestation-pool seeding. +//! Synthetic benchmark corpus: deterministic validators, a genesis store, +//! per-slot attestation-pool seeding, and the crypto-mode-specific steps of one +//! slot (seal, import, which phases to expect). use std::sync::Arc; +use std::time::Instant; -use ethlambda_blockchain::store::produce_attestation_data; +use ethlambda_blockchain::block_builder::seal_block; +use ethlambda_blockchain::key_manager::KeyManager; +use ethlambda_blockchain::metrics::{ + BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES, BLOCK_PROPOSAL_SEAL_PHASES, +}; +use ethlambda_blockchain::store::{ + StoreError, on_block, on_block_without_verification, produce_attestation_data, +}; +use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ - attestation::{AggregationBits, HashedAttestationData}, - block::SingleMessageAggregate, + attestation::{AggregationBits, HashedAttestationData, validator_indices}, + block::{Block, MultiMessageAggregate, SignedBlock, SingleMessageAggregate}, constants::DEFAULT_MILLISECONDS_PER_SLOT, + primitives::HashTreeRoot as _, state::{State, Validator, ValidatorPubkeyBytes}, }; +use eyre::WrapErr as _; /// Fixed genesis time for synthetic runs. The harness derives every tick /// timestamp from slot numbers relative to this value and never reads the wall /// clock, so runs are reproducible at any time of day. const GENESIS_TIME: u64 = 1_700_000_000; +/// Everything that differs between a mock and a real-crypto run. +pub(crate) enum CryptoMode { + /// Empty placeholder proofs and placeholder pubkey bytes, no seal, and + /// unverified import. No code path decodes the placeholders. + Mock, + /// Real XMSS signatures aggregated into real leanVM type-1 proofs, the + /// proposer's real seal, and verified import. + Real { + /// `(attestation_pubkey, proposal_pubkey)` per validator, for genesis. + genesis_pubkeys: Vec<(ValidatorPubkeyBytes, ValidatorPubkeyBytes)>, + /// Signs attestations for the corpus and block roots for the seal. + key_manager: KeyManager, + }, +} + +/// What one slot's seeding produced. +pub(crate) struct SeedOutcome { + /// Pool entries (new + known) the next build will see. + pub pool_entries: usize, + /// Seconds spent signing and aggregating this slot's entries; 0 in mock mode. + pub aggregate_seconds: f64, +} + pub(crate) struct SyntheticCorpus { num_validators: u64, proofs_per_data: u64, + crypto: CryptoMode, } impl SyntheticCorpus { - pub(crate) fn new(num_validators: u64, proofs_per_data: u64) -> Self { + pub(crate) fn new(num_validators: u64, proofs_per_data: u64, crypto: CryptoMode) -> Self { Self { num_validators, proofs_per_data, + crypto, } } /// Build a genesis store over an in-memory backend with `num_validators` - /// seed-derived validators. - /// - /// Pubkeys are deterministic placeholder bytes: in mock-crypto mode no code - /// path decodes them (signature verification is skipped and best-proof - /// compaction never resolves pubkeys). + /// validators: the key set's pubkeys in real mode, seed-derived placeholder + /// bytes in mock mode. pub(crate) fn genesis_store(&self, seed: u64) -> Store { let mut rng_state = seed; let validators = (0..self.num_validators) - .map(|index| Validator { - attestation_pubkey: synthetic_pubkey(&mut rng_state), - proposal_pubkey: synthetic_pubkey(&mut rng_state), - index, + .map(|index| { + let (attestation_pubkey, proposal_pubkey) = match &self.crypto { + CryptoMode::Mock => ( + synthetic_pubkey(&mut rng_state), + synthetic_pubkey(&mut rng_state), + ), + CryptoMode::Real { + genesis_pubkeys, .. + } => genesis_pubkeys[index as usize], + }; + Validator { + attestation_pubkey, + proposal_pubkey, + index, + } }) .collect(); let genesis_state = State::from_genesis(GENESIS_TIME, validators); @@ -58,27 +103,69 @@ impl SyntheticCorpus { /// /// Mirrors what committee aggregators gossip during a slot: several /// aggregates for the same `AttestationData`, each covering a validator - /// subset. The proposal tick then promotes them to the known pool, exactly - /// as on a live node. Entries are inserted in a fixed order because pool - /// insertion order pins within-entry proof choice during selection. - /// - /// Returns the total number of pool entries the next build will see, across - /// both pools. + /// subset. In real mode each subset's validators sign the data and the + /// signatures are aggregated into a type-1 proof; in mock mode the proofs + /// are empty. The proposal tick then promotes the entries to the known + /// pool, exactly as on a live node. Entries are inserted in a fixed order + /// because pool insertion order pins within-entry proof choice during + /// selection. pub(crate) fn seed_pool( - &self, + &mut self, store: &mut Store, attestation_slot: u64, - ) -> eyre::Result { + ) -> eyre::Result { let data = produce_attestation_data(store, attestation_slot); - let entries = participant_groups(self.num_validators, self.proofs_per_data) - .into_iter() - .map(|participants| { - ( - HashedAttestationData::new(data.clone()), - SingleMessageAggregate::empty(participants), - ) - }) - .collect(); + let hashed = HashedAttestationData::new(data.clone()); + let groups = participant_groups(self.num_validators, self.proofs_per_data); + + let (entries, aggregate_seconds) = match &mut self.crypto { + CryptoMode::Mock => { + let entries = groups + .into_iter() + .map(|participants| { + (hashed.clone(), SingleMessageAggregate::empty(participants)) + }) + .collect(); + (entries, 0.0) + } + CryptoMode::Real { key_manager, .. } => { + let start = Instant::now(); + let validators = store.head_state().validators; + let message = data.hash_tree_root(); + let slot: u32 = attestation_slot.try_into().expect("slot exceeds u32"); + let mut entries = Vec::with_capacity(groups.len()); + for participants in groups { + let mut pubkeys = Vec::new(); + let mut signatures = Vec::new(); + for validator in validator_indices(&participants) { + let pubkey_bytes = &validators + .get(validator as usize) + .ok_or_else(|| eyre::eyre!("validator {validator} not in state"))? + .attestation_pubkey; + pubkeys.push(ValidatorPublicKey::from_bytes(pubkey_bytes)?); + let signature = key_manager + .sign_attestation(validator, &data) + .wrap_err_with(|| { + format!("validator {validator} failed to sign slot {slot}") + })?; + signatures.push(ValidatorSignature::from_bytes(&signature)?); + } + let count = signatures.len(); + let proof = + ethlambda_crypto::aggregate_signatures(pubkeys, signatures, &message, slot) + .wrap_err_with(|| { + format!( + "type-1 aggregation of {count} signatures failed at slot {slot}" + ) + })?; + entries.push(( + hashed.clone(), + SingleMessageAggregate::new(participants, proof), + )); + } + (entries, start.elapsed().as_secs_f64()) + } + }; store.insert_new_aggregated_payloads_batch(entries); // The pending pool evicts whole data-root entries FIFO once its proof @@ -90,7 +177,53 @@ impl SyntheticCorpus { "attestations seeded for slot {attestation_slot} were evicted from the pending pool; \ the measured workload would not match the requested parameters" ); - Ok(pending + store.known_aggregated_payloads_count()) + Ok(SeedOutcome { + pool_entries: pending + store.known_aggregated_payloads_count(), + aggregate_seconds, + }) + } + + /// Turn the built block into a `SignedBlock` the way the proposer does. Mock + /// mode has nothing to sign with, so it ships an empty proof, the way the + /// fork-choice spec tests do. + pub(crate) fn seal( + &mut self, + store: &Store, + block: Block, + aggregates: Vec, + ) -> eyre::Result { + match &mut self.crypto { + CryptoMode::Mock => Ok(SignedBlock { + message: block, + proof: MultiMessageAggregate::default(), + }), + CryptoMode::Real { key_manager, .. } => { + let head_state = store.head_state(); + Ok(seal_block(&head_state, key_manager, block, aggregates)?) + } + } + } + + /// Import the sealed block. Real mode verifies the merged proof, so a bad + /// seal fails the run instead of producing a report about invalid blocks. + pub(crate) fn import(&self, store: &mut Store, block: SignedBlock) -> Result<(), StoreError> { + match self.crypto { + CryptoMode::Mock => on_block_without_verification(store, block), + CryptoMode::Real { .. } => on_block(store, block), + } + } + + /// The phases one slot observes exactly once: the build phases always, plus + /// the seal phases when the seal runs. + pub(crate) fn phases(&self) -> impl Iterator { + let seal = match self.crypto { + CryptoMode::Mock => &[][..], + CryptoMode::Real { .. } => BLOCK_PROPOSAL_SEAL_PHASES, + }; + BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES + .iter() + .chain(seal) + .copied() } } @@ -135,7 +268,6 @@ fn synthetic_pubkey(rng_state: &mut u64) -> ValidatorPubkeyBytes { #[cfg(test)] mod tests { use super::*; - use ethlambda_types::attestation::validator_indices; #[test] fn participant_groups_partition_all_validators() { @@ -165,4 +297,50 @@ mod tests { let mut c = 43u64; assert_ne!(synthetic_pubkey(&mut a), synthetic_pubkey(&mut c)); } + + /// Real seeding produces a proof the verifier accepts for exactly the + /// participants it claims. Runs the leanVM prover, so it is opt-in like the + /// crypto crate's own aggregation tests. + #[test] + #[ignore = "too slow"] + fn real_seeding_produces_verifiable_proofs() { + use crate::benchmark::keys::KeySet; + let keys = KeySet::generate(1, 2, 2, None).unwrap(); + let mut corpus = SyntheticCorpus::new( + 2, + 1, + CryptoMode::Real { + genesis_pubkeys: keys.genesis_pubkeys, + key_manager: keys.key_manager, + }, + ); + let mut store = corpus.genesis_store(1); + let outcome = corpus.seed_pool(&mut store, 0).unwrap(); + assert_eq!(outcome.pool_entries, 1); + assert!(outcome.aggregate_seconds > 0.0); + + let data = produce_attestation_data(&store, 0); + store.promote_new_aggregated_payloads(); + let (_, proofs) = store + .known_aggregated_payloads() + .into_values() + .next() + .expect("one seeded entry"); + let proof = &proofs[0]; + let validators = store.head_state().validators; + let pubkeys = proof + .participant_indices() + .map(|index| { + ValidatorPublicKey::from_bytes(&validators[index as usize].attestation_pubkey) + .unwrap() + }) + .collect(); + ethlambda_crypto::verify_aggregated_signature( + &proof.proof, + pubkeys, + &data.hash_tree_root(), + 0, + ) + .expect("seeded proof verifies"); + } } diff --git a/bin/ethlambda/src/benchmark/keys.rs b/bin/ethlambda/src/benchmark/keys.rs new file mode 100644 index 00000000..c857b7ab --- /dev/null +++ b/bin/ethlambda/src/benchmark/keys.rs @@ -0,0 +1,210 @@ +//! Seeded XMSS validator keys for real-crypto benchmark runs. +//! +//! Every validator gets an attestation key and a proposal key derived from the +//! run seed, so two runs with the same seed use identical keys and, since XMSS +//! signing is deterministic, identical signatures and proofs. Keys are +//! generated only for the slots the run will sign (leansig's keygen cost scales +//! with the active window), in parallel, and `--key-cache` stores them so +//! reruns skip keygen. + +use std::collections::HashMap; +use std::path::Path; +use std::time::Instant; + +use ethlambda_blockchain::key_manager::{KeyManager, ValidatorKeyPair}; +use ethlambda_crypto::signature::ValidatorSecretKey; +use ethlambda_types::state::ValidatorPubkeyBytes; +use eyre::WrapErr as _; +use rayon::prelude::*; + +const PUBKEY_LEN: usize = size_of::(); + +#[derive(Debug, Clone, Copy)] +#[repr(u64)] +enum Role { + Attestation = 0, + Proposal = 1, +} + +impl Role { + fn tag(self) -> &'static str { + match self { + Role::Attestation => "attestation", + Role::Proposal => "proposal", + } + } +} + +struct Key { + pubkey: ValidatorPubkeyBytes, + secret: ValidatorSecretKey, + cached: bool, +} + +pub(crate) struct KeySet { + /// `(attestation_pubkey, proposal_pubkey)` per validator, for the genesis state. + pub genesis_pubkeys: Vec<(ValidatorPubkeyBytes, ValidatorPubkeyBytes)>, + /// The production signer over every validator's keys, so the benchmark + /// signs through exactly the code path the node uses. + pub key_manager: KeyManager, +} + +impl KeySet { + /// Generate, or load from `cache`, keys for `num_validators` validators, each + /// active for epochs (slots) `0..num_slots`. + /// + /// Cache entries are keyed by the leansig revision, seed, validator index, + /// role and window, so a leansig bump or a different run shape never reuses + /// a stale key. + pub(crate) fn generate( + seed: u64, + num_validators: u64, + num_slots: u64, + cache: Option<&Path>, + ) -> eyre::Result { + if let Some(dir) = cache { + std::fs::create_dir_all(dir) + .wrap_err_with(|| format!("failed to create key cache {}", dir.display()))?; + } + let num_active_epochs = usize::try_from(num_slots) + .ok() + .filter(|epochs| *epochs >= 1) + .ok_or_else(|| eyre::eyre!("key window must cover at least one slot"))?; + + // Every key is independent and deterministic in (seed, index, role), so + // they are produced in parallel; `collect` keeps the job order. + let start = Instant::now(); + let jobs: Vec<(u64, Role)> = (0..num_validators) + .flat_map(|index| [(index, Role::Attestation), (index, Role::Proposal)]) + .collect(); + let keys: Vec = jobs + .into_par_iter() + .map(|(index, role)| load_or_generate(seed, index, role, num_active_epochs, cache)) + .collect::>()?; + let cached = keys.iter().filter(|key| key.cached).count(); + eprintln!( + "validator keys ready in {:.1}s ({} generated, {cached} loaded from cache)", + start.elapsed().as_secs_f64(), + keys.len() - cached, + ); + + let mut genesis_pubkeys = Vec::with_capacity(num_validators as usize); + let mut pairs = HashMap::with_capacity(num_validators as usize); + let mut keys = keys.into_iter(); + for index in 0..num_validators { + let (attestation, proposal) = (keys.next(), keys.next()); + let (Some(attestation), Some(proposal)) = (attestation, proposal) else { + eyre::bail!("key generation produced fewer keys than validators"); + }; + genesis_pubkeys.push((attestation.pubkey, proposal.pubkey)); + pairs.insert( + index, + ValidatorKeyPair { + attestation_key: attestation.secret, + proposal_key: proposal.secret, + }, + ); + } + Ok(Self { + genesis_pubkeys, + key_manager: KeyManager::new(pairs), + }) + } +} + +/// Load the cached key for `(seed, index, role)` if present, else derive it +/// (and cache it when a cache directory is given). +fn load_or_generate( + seed: u64, + index: u64, + role: Role, + num_active_epochs: usize, + cache: Option<&Path>, +) -> eyre::Result { + let file = cache.map(|dir| { + dir.join(format!( + "xmss-{}-seed{seed}-v{index}-{}-w{num_active_epochs}.bin", + env!("ETHLAMBDA_LEANSIG_REV"), + role.tag() + )) + }); + if let Some(file) = &file + && file.is_file() + { + return load_cached(file); + } + + let key_seed = seed ^ (index << 1 | role as u64).rotate_left(32); + let (pubkey, secret) = ValidatorSecretKey::generate_from_seed(key_seed, 0, num_active_epochs); + let pubkey: ValidatorPubkeyBytes = pubkey.to_bytes().try_into().map_err(|bytes: Vec| { + eyre::eyre!( + "leansig pubkey is {} bytes, expected {PUBKEY_LEN}", + bytes.len() + ) + })?; + if let Some(file) = &file { + let mut bytes = pubkey.to_vec(); + bytes.extend_from_slice(&secret.to_bytes()); + std::fs::write(file, bytes) + .wrap_err_with(|| format!("failed to write cached key {}", file.display()))?; + } + Ok(Key { + pubkey, + secret, + cached: false, + }) +} + +/// A cache entry is the pubkey bytes followed by the serialized secret key. +fn load_cached(file: &Path) -> eyre::Result { + let bytes = std::fs::read(file) + .wrap_err_with(|| format!("failed to read cached key {}", file.display()))?; + eyre::ensure!( + bytes.len() > PUBKEY_LEN, + "cached key {} is truncated; delete it and rerun", + file.display() + ); + let (pubkey, secret) = bytes.split_at(PUBKEY_LEN); + let secret = ValidatorSecretKey::from_bytes(secret).map_err(|err| { + eyre::eyre!( + "cached key {} does not decode ({err}); delete it and rerun", + file.display() + ) + })?; + Ok(Key { + pubkey: pubkey.try_into().expect("split at PUBKEY_LEN"), + secret, + cached: true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_are_deterministic_per_seed_and_distinct_per_role() { + let a = load_or_generate(7, 3, Role::Attestation, 2, None).unwrap(); + let b = load_or_generate(7, 3, Role::Attestation, 2, None).unwrap(); + assert_eq!(a.pubkey, b.pubkey); + assert_eq!(a.secret.to_bytes(), b.secret.to_bytes()); + let proposal = load_or_generate(7, 3, Role::Proposal, 2, None).unwrap(); + assert_ne!(a.pubkey, proposal.pubkey); + let other_seed = load_or_generate(8, 3, Role::Attestation, 2, None).unwrap(); + assert_ne!(a.pubkey, other_seed.pubkey); + } + + #[test] + fn cache_round_trips_and_decodes() { + let dir = tempfile::tempdir().unwrap(); + let first = KeySet::generate(11, 1, 2, Some(dir.path())).unwrap(); + let mut second = KeySet::generate(11, 1, 2, Some(dir.path())).unwrap(); + assert_eq!(first.genesis_pubkeys, second.genesis_pubkeys); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2); + assert_eq!(second.key_manager.validator_ids(), vec![0]); + second + .key_manager + .sign_block_root(0, 1, ðlambda_types::primitives::H256::ZERO) + .expect("cached key signs within its window"); + } +} diff --git a/bin/ethlambda/src/benchmark/mod.rs b/bin/ethlambda/src/benchmark/mod.rs index 869ddcce..95f42855 100644 --- a/bin/ethlambda/src/benchmark/mod.rs +++ b/bin/ethlambda/src/benchmark/mod.rs @@ -1,15 +1,18 @@ //! Offline block-building benchmark (`ethlambda benchmark`). //! -//! Drives the exact production proposer path — `produce_block_with_signatures`, -//! the same entry `BlockChainServer::propose_block` uses — against a synthetic -//! in-memory chain, and reports per-phase timing distributions. Gossip publish -//! and the slot-alignment sleep are outside the measured span, matching the -//! node's own `lean_block_building_time_seconds` boundary. +//! Drives the exact production proposer path — `produce_block_with_signatures` +//! then `seal_block`, the same entries `BlockChainServer::propose_block` uses — +//! against a synthetic in-memory chain, and reports per-phase timing +//! distributions. Gossip publish and the slot-alignment sleep are outside the +//! measured span, matching the node's own `lean_block_building_time_seconds` +//! boundary. With `--mock-crypto` the seal is skipped (there are no keys to +//! sign with) and only the build is measured. //! //! See docs/benchmarking.md for what is and is not measured, how to read a //! report, and the current limitations. mod corpus; +mod keys; mod report; use std::collections::{BTreeMap, HashMap}; @@ -17,13 +20,12 @@ use std::path::PathBuf; use std::time::Instant; use ethlambda_blockchain::block_builder::ProposerConfig; -use ethlambda_blockchain::metrics::BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES; -use ethlambda_blockchain::store::{on_block_without_verification, produce_block_with_signatures}; +use ethlambda_blockchain::store::produce_block_with_signatures; use ethlambda_storage::{NEW_PAYLOAD_CAP, Store}; -use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; use ethlambda_types::primitives::HashTreeRoot as _; use eyre::WrapErr as _; +use corpus::CryptoMode; use report::{Environment, Params, Report, Sample}; #[derive(Debug, clap::Args)] @@ -63,16 +65,17 @@ struct SyntheticOptions { /// same seed and parameters produce identical per-iteration block roots. #[arg(long, default_value = "42")] seed: u64, + /// Directory caching the seed-derived XMSS keys, so reruns skip key + /// generation (about a second per key). Entries are keyed by leansig + /// revision, seed, validator index and run length. Real crypto only. + #[arg(long, conflicts_with = "mock_crypto")] + key_cache: Option, #[command(flatten)] common: CommonOptions, } impl SyntheticOptions { fn validate(&self) -> eyre::Result<()> { - eyre::ensure!( - self.common.mock_crypto, - "real-crypto benchmarking is not implemented yet; rerun with --mock-crypto" - ); // The pending pool evicts whole data-root entries FIFO once its proof // cap is exceeded, so a single slot's batch larger than the cap would // silently seed nothing and every measured block would be empty. @@ -108,9 +111,10 @@ struct CommonOptions { #[arg(long, default_value = "10", value_parser = clap::value_parser!(u64).range(1..))] iterations: u64, /// Seed pools with empty placeholder proofs instead of real XMSS/leanVM - /// crypto. Measures selection + best-proof compaction + state transition - /// only; runs in seconds. Conflicts with --enable-proposer-aggregation, - /// whose recursive aggregation needs real proof bytes. + /// crypto, and skip the seal. Measures selection + best-proof compaction + + /// state transition only; runs in seconds. Conflicts with + /// --enable-proposer-aggregation, whose recursive aggregation needs real + /// proof bytes. #[arg(long, conflicts_with = "enable_proposer_aggregation")] mock_crypto: bool, /// Mirrors the node flag: collapse same-data proofs via recursive leanVM @@ -148,18 +152,35 @@ fn run_synthetic(options: SyntheticOptions) -> eyre::Result<()> { enable_proposer_aggregation: common.enable_proposer_aggregation, max_attestations_per_block: common.max_attestations_per_block, }; - let corpus = corpus::SyntheticCorpus::new(options.num_validators, options.proofs_per_data); - let mut store = corpus.genesis_store(options.seed); - let total_slots = options .warmup_slots .checked_add(common.iterations) .ok_or_else(|| eyre::eyre!("--warmup-slots plus --iterations overflows u64"))?; + // Attestations are signed for slots 0..total_slots and blocks for + // 1..=total_slots, so the keys must be active for total_slots + 1 epochs. + let crypto = if common.mock_crypto { + CryptoMode::Mock + } else { + let keys = keys::KeySet::generate( + options.seed, + options.num_validators, + total_slots + 1, + options.key_cache.as_deref(), + )?; + CryptoMode::Real { + genesis_pubkeys: keys.genesis_pubkeys, + key_manager: keys.key_manager, + } + }; + let mut corpus = + corpus::SyntheticCorpus::new(options.num_validators, options.proofs_per_data, crypto); + let mut store = corpus.genesis_store(options.seed); + let mut samples = Vec::with_capacity(common.iterations as usize); for slot in 1..=total_slots { let sample = build_one_slot( - &corpus, + &mut corpus, &mut store, slot, options.num_validators, @@ -201,20 +222,23 @@ fn log_progress(slot: u64, total_slots: u64, measured: bool, sample: &Sample) { let label = if measured { "measured" } else { "warmup" }; eprintln!( "[{slot}/{total_slots}] {label}: built block in {:.3}ms \ - (attestations={}, pool_entries={})", + (attestations={}, pool_entries={}, aggregate={:.3}s, import={:.3}s)", sample.wall_seconds * 1e3, sample.attestations_packed, sample.pool_entries, + sample.aggregate_seconds, + sample.import_seconds, ); } -/// Seed the pool, build one block the way the proposer does, and import it. +/// Seed the pool, build and seal one block the way the proposer does, and +/// import it. /// /// The returned sample carries `iteration: 0`; the caller sets it for the slots /// it keeps. Warmup and measured slots do exactly the same work — only whether /// the sample is kept differs — so there is one code path for both. fn build_one_slot( - corpus: &corpus::SyntheticCorpus, + corpus: &mut corpus::SyntheticCorpus, store: &mut Store, slot: u64, num_validators: u64, @@ -223,8 +247,11 @@ fn build_one_slot( // Seed the pending pool with the previous slot's attestations, exactly // where gossip aggregates would sit before the proposal tick promotes them // to the known pool. Entries from earlier slots stay in the known pool, as - // they would on a live node. - let pool_entries = corpus.seed_pool(store, slot - 1)?; + // they would on a live node. Signing and aggregating them is aggregator + // work, so it is timed separately and kept out of the measured span. + let seeded = corpus + .seed_pool(store, slot - 1) + .wrap_err_with(|| format!("seeding the pool failed for slot {slot}"))?; // Round-robin proposer, matching `is_proposer`. let proposer = slot % num_validators; @@ -234,23 +261,25 @@ fn build_one_slot( let (block, aggregates, _checkpoints) = produce_block_with_signatures(store, slot, proposer, proposer_config) .wrap_err_with(|| format!("block build failed at slot {slot}"))?; + let aggregates_count = aggregates.len(); + let signed_block = corpus + .seal(store, block, aggregates) + .wrap_err_with(|| format!("sealing the block failed at slot {slot}"))?; let wall_seconds = build_start.elapsed().as_secs_f64(); - let phases = phases.finish()?; + let phases = phases.finish(corpus.phases())?; - let block_root = block.hash_tree_root(); - let attestations_packed = block.body.attestations.len(); - let aggregates_count = aggregates.len(); + let block_root = signed_block.message.hash_tree_root(); + let attestations_packed = signed_block.message.body.attestations.len(); // Import the built block (outside the measured span) so the next iteration // builds one slot ahead of head, like a live proposer; building repeatedly // on a fixed head would make `process_slots` cost grow with the iteration // index. - let signed_block = SignedBlock { - message: block, - proof: MultiMessageAggregate::default(), - }; - on_block_without_verification(store, signed_block) + let import_start = Instant::now(); + corpus + .import(store, signed_block) .wrap_err_with(|| format!("importing the built block failed at slot {slot}"))?; + let import_seconds = import_start.elapsed().as_secs_f64(); // Clamped: the unattributed preamble makes the remainder positive in // practice, but summing many small phase values can round just above the @@ -267,14 +296,16 @@ fn build_one_slot( overhead_seconds, attestations_packed, aggregates: aggregates_count, - pool_entries, + pool_entries: seeded.pool_entries, + aggregate_seconds: seeded.aggregate_seconds, + import_seconds, }) } const PHASE_HISTOGRAM: &str = "lean_block_proposal_attestation_build_phase_seconds"; -/// Exact per-phase durations for one block build, taken from the block-proposal -/// phase histogram in the default prometheus registry. +/// Exact per-phase durations for one block build and seal, taken from the +/// block-proposal phase histogram in the default prometheus registry. /// /// Histogram sums accumulate the raw f64 seconds of every observation, so the /// difference between two readings IS the build's phase time — bucket @@ -289,16 +320,19 @@ impl PhaseTimer { Self { before: read() } } - /// Per-phase durations since [`PhaseTimer::start`]. + /// Per-phase durations since [`PhaseTimer::start`] for `expected` phases. /// - /// Each phase must have been observed exactly once — one `build_block` in - /// this single-threaded process — so anything else means the accounting - /// drifted and attribution would be wrong. That is a hard error, not a - /// warning: a silently mis-attributed report is worse than no report. - fn finish(self) -> eyre::Result> { + /// Each phase must have been observed exactly once — one build (and one + /// seal) in this single-threaded process — so anything else means the + /// accounting drifted and attribution would be wrong. That is a hard error, + /// not a warning: a silently mis-attributed report is worse than no report. + fn finish( + self, + expected: impl Iterator, + ) -> eyre::Result> { let after = read(); let mut phases = BTreeMap::new(); - for &phase in BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES { + for phase in expected { let (sum_before, count_before) = self.before.get(phase).copied().unwrap_or((0.0, 0)); let (sum_after, count_after) = after.get(phase).copied().unwrap_or((0.0, 0)); let observations = count_after.saturating_sub(count_before); diff --git a/bin/ethlambda/src/benchmark/report.rs b/bin/ethlambda/src/benchmark/report.rs index 34e8014c..117f5884 100644 --- a/bin/ethlambda/src/benchmark/report.rs +++ b/bin/ethlambda/src/benchmark/report.rs @@ -36,6 +36,14 @@ pub(crate) struct Sample { /// Pool entries (new + known) visible to this build; reported so pool /// growth across iterations is visible in the samples. pub pool_entries: usize, + /// Seconds spent producing this slot's pool entries: every validator's + /// XMSS attestation signature plus their type-1 aggregation. That is + /// aggregator-side work a proposer never does, so it sits outside `wall`. + /// Zero in mock mode. + pub aggregate_seconds: f64, + /// Seconds to import the block after the measured span; in real mode this + /// includes verifying the merged multi-message aggregate. + pub import_seconds: f64, } #[derive(Debug, Serialize)] @@ -97,6 +105,8 @@ pub(crate) struct Summary { pub phases: BTreeMap, pub overhead: Stats, pub wall: Stats, + pub aggregate: Stats, + pub import: Stats, } #[derive(Debug, Serialize)] @@ -120,18 +130,12 @@ impl Report { phases.insert(phase.clone(), stats(&values)); } } - let overhead = stats( - &samples - .iter() - .map(|sample| sample.overhead_seconds) - .collect::>(), - ); - let wall = stats( - &samples - .iter() - .map(|sample| sample.wall_seconds) - .collect::>(), - ); + let column = + |value: fn(&Sample) -> f64| stats(&samples.iter().map(value).collect::>()); + let overhead = column(|sample| sample.overhead_seconds); + let wall = column(|sample| sample.wall_seconds); + let aggregate = column(|sample| sample.aggregate_seconds); + let import = column(|sample| sample.import_seconds); if wall.cv > CV_WARN_THRESHOLD { eprintln!( @@ -151,6 +155,8 @@ impl Report { phases, overhead, wall, + aggregate, + import, }, } } @@ -205,7 +211,11 @@ impl Report { for phase in &phases { let _ = write!(out, " {phase:>16}"); } - let _ = writeln!(out, " {:>10} {:>10} {:>12}", "overhead", "wall", "root"); + let _ = writeln!( + out, + " {:>10} {:>10} {:>10} {:>10} {:>12}", + "overhead", "wall", "aggregate", "import", "root" + ); for sample in &self.samples { let _ = write!(out, " {:<5}", sample.iteration); @@ -215,9 +225,11 @@ impl Report { } let _ = writeln!( out, - " {:>10} {:>10} {:>12}", + " {:>10} {:>10} {:>10} {:>10} {:>12}", format_ms(sample.overhead_seconds), format_ms(sample.wall_seconds), + format_ms(sample.aggregate_seconds), + format_ms(sample.import_seconds), &sample.block_root[..10], ); } @@ -233,6 +245,10 @@ impl Report { } let _ = writeln!(out, "{}", stats_row("overhead", &self.summary.overhead)); let _ = writeln!(out, "{}", stats_row("wall", &self.summary.wall)); + let _ = writeln!(out); + let _ = writeln!(out, " outside the measured span:"); + let _ = writeln!(out, "{}", stats_row("aggregate", &self.summary.aggregate)); + let _ = writeln!(out, "{}", stats_row("import", &self.summary.import)); out } } diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index f7f4f25e..f6255964 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -15,7 +15,10 @@ use std::{ time::Instant, }; -use ethlambda_crypto::{aggregate_proofs, signature::ValidatorPublicKey}; +use ethlambda_crypto::{ + AggregationError, aggregate_proofs, aggregate_signatures, merge_type_1s_into_type_2, + signature::{SignatureParseError, ValidatorPublicKey, ValidatorSignature}, +}; use ethlambda_state_transition::{ attestation_data_matches_chain, justified_slots_ops, process_block, process_slots, slot_is_justifiable_after, @@ -23,14 +26,22 @@ use ethlambda_state_transition::{ use ethlambda_types::{ ShortRoot, attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{AggregatedAttestations, Block, BlockBody, SingleMessageAggregate}, + block::{ + AggregatedAttestations, Block, BlockBody, MultiMessageAggregate, + MultiMessageAggregateError, SignedBlock, SingleMessageAggregate, + }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - state::{JustifiedSlots, State}, + state::{JustifiedSlots, State, Validator}, }; use tracing::{info, trace}; -use crate::{MAX_ATTESTATIONS_DATA, metrics, store::StoreError}; +use crate::{ + MAX_ATTESTATIONS_DATA, + key_manager::{KeyManager, KeyManagerError}, + metrics, + store::StoreError, +}; /// Post-block checkpoints extracted from the state transition in `build_block`. /// @@ -665,20 +676,7 @@ fn compact_attestations( let children: Vec<(Vec<_>, _)> = group_items .iter() .map(|(_, proof)| { - let pubkeys = proof - .participant_indices() - .map(|vid| { - let not_in_state = StoreError::ValidatorNotInState { - validator_index: vid, - }; - let validator = head_state - .validators - .get(vid as usize) - .ok_or(not_in_state)?; - ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) - .map_err(|_| StoreError::PubkeyDecodingFailed(vid)) - }) - .collect::, _>>()?; + let pubkeys = resolve_attestation_pubkeys(&head_state.validators, proof)?; Ok((pubkeys, proof.proof.clone())) }) .collect::, StoreError>>()?; @@ -887,6 +885,116 @@ fn trace_skipped_attestation(reason: &'static str, att: &AttestationData, data_r ); } +/// Decode the attestation pubkeys of a proof's participants from the state. +fn resolve_attestation_pubkeys( + validators: &[Validator], + proof: &SingleMessageAggregate, +) -> Result, StoreError> { + proof + .participant_indices() + .map(|vid| { + let validator = + validators + .get(vid as usize) + .ok_or(StoreError::ValidatorNotInState { + validator_index: vid, + })?; + ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) + .map_err(|_| StoreError::PubkeyDecodingFailed(vid)) + }) + .collect() +} + +/// Why sealing a built block failed. +#[derive(Debug, thiserror::Error)] +pub enum SealError { + #[error("failed to sign block root: {0}")] + Signing(#[from] KeyManagerError), + #[error("proposer index {0} out of range")] + ProposerOutOfRange(u64), + #[error("failed to decode proposer proposal pubkey: {0}")] + ProposerPubkey(SignatureParseError), + #[error("failed to decode proposer signature bytes: {0}")] + ProposerSignature(SignatureParseError), + #[error("failed to resolve participant pubkeys: {0}")] + Participants(#[from] StoreError), + #[error("failed to wrap proposer signature as single-message aggregate: {0}")] + Wrap(AggregationError), + #[error("failed to merge single-message aggregates into a multi-message aggregate: {0}")] + Merge(AggregationError), + #[error("failed to build multi-message aggregate: {0}")] + Decode(#[from] MultiMessageAggregateError), +} + +/// Seal a built block into a `SignedBlock`: sign the block root with the +/// proposer's proposal key, wrap that raw XMSS signature into a singleton +/// single-message aggregate SNARK, then merge it with every attestation +/// single-message aggregate into the block's single multi-message aggregate. +/// +/// `single_message_aggregates` are the proofs `build_block` returned alongside +/// `block`, in the same order as `block.body.attestations`; they are consumed +/// so their proof bytes move into the merge instead of being copied. +/// Per-component participants are rederived at verify time from those +/// attestations' `aggregation_bits` plus `block.proposer_index`, so nothing +/// else needs persisting. +/// +/// Each step is observed on the block-proposal phase histogram under +/// [`metrics::BLOCK_PROPOSAL_SEAL_PHASES`]. +pub fn seal_block( + head_state: &State, + key_manager: &mut KeyManager, + block: Block, + single_message_aggregates: Vec, +) -> Result { + let slot: u32 = block.slot.try_into().expect("slot exceeds u32"); + let proposer_index = block.proposer_index; + let block_root = block.hash_tree_root(); + + let sign_start = Instant::now(); + let proposer_signature = key_manager.sign_block_root(proposer_index, slot, &block_root)?; + metrics::observe_block_proposal_phase("sign_proposer", sign_start.elapsed()); + + let validators = &head_state.validators; + let proposer_validator = validators + .get(proposer_index as usize) + .ok_or(SealError::ProposerOutOfRange(proposer_index))?; + + // Decode the proposer's proposal pubkey once and reuse it both for the + // singleton single-message aggregate wrap and for the multi-message + // aggregate merge inputs. + let proposer_pubkey = ValidatorPublicKey::from_bytes(&proposer_validator.proposal_pubkey) + .map_err(SealError::ProposerPubkey)?; + let proposer_validator_signature = ValidatorSignature::from_bytes(&proposer_signature) + .map_err(SealError::ProposerSignature)?; + + let wrap_start = Instant::now(); + let proposer_proof_bytes = aggregate_signatures( + vec![proposer_pubkey.clone()], + vec![proposer_validator_signature], + &block_root, + slot, + ) + .map_err(SealError::Wrap)?; + metrics::observe_block_proposal_phase("wrap_proposer", wrap_start.elapsed()); + + let mut merge_inputs = Vec::with_capacity(single_message_aggregates.len() + 1); + for sma in single_message_aggregates { + let pubkeys = resolve_attestation_pubkeys(validators, &sma)?; + merge_inputs.push((pubkeys, sma.proof)); + } + merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); + + let merge_start = Instant::now(); + let merged_bytes = merge_type_1s_into_type_2(merge_inputs).map_err(SealError::Merge)?; + let proof = MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice())?; + metrics::observe_block_proposal_phase("merge_type2", merge_start.elapsed()); + + Ok(SignedBlock { + message: block, + proof, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 8d678e22..3e1d97f7 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -1,7 +1,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::time::{Duration, Instant, SystemTime}; -use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature}; use ethlambda_network_api::{BlockChainToP2PRef, BlockSource, InitP2P}; use ethlambda_state_transition::is_proposer; use ethlambda_storage::{ALL_TABLES, Store}; @@ -9,7 +8,7 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ByteList512KiB, MultiMessageAggregate, SignedBlock}, + block::SignedBlock, chain_config::ChainConfig, primitives::{H256, HashTreeRoot as _}, }; @@ -780,118 +779,21 @@ impl BlockChainServer { block.body.attestations.iter(), ); - // Sign the block root with the proposal key - let block_root = block.hash_tree_root(); - let Ok(proposer_signature) = self - .key_manager - .sign_block_root(validator_id, slot as u32, &block_root) - .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to sign block root")) - else { - metrics::inc_block_building_failures(); - return; - }; - - // Wrap the proposer's raw XMSS signature into a singleton - // single-message aggregate SNARK, then merge it with every attestation - // single-message aggregate into the single multi-message aggregate. + // Sign the block root, wrap the signature as a singleton single-message + // aggregate, and merge it with every attestation aggregate into the + // block's multi-message aggregate. let head_state = self.store.head_state(); - let validators = &head_state.validators; - let Some(proposer_validator) = validators.get(validator_id as usize) else { - error!(%slot, %validator_id, "Proposer index out of range when assembling block"); - metrics::inc_block_building_failures(); - return; - }; - - // Decode the proposer's proposal pubkey once and reuse it both for the - // singleton single-message aggregate wrap and for the multi-message - // aggregate merge inputs. - let Ok(proposer_pubkey) = ValidatorPublicKey::from_bytes( - &proposer_validator.proposal_pubkey, - ) - .inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), - ) else { - metrics::inc_block_building_failures(); - return; - }; - - let Ok(proposer_validator_signature) = - ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { - error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") - }) - else { - metrics::inc_block_building_failures(); - return; - }; - let Ok(proposer_proof_bytes) = ethlambda_crypto::aggregate_signatures( - vec![proposer_pubkey.clone()], - vec![proposer_validator_signature], - &block_root, - slot as u32, + let Ok(signed_block) = block_builder::seal_block( + &head_state, + &mut self.key_manager, + block, + single_message_aggregates, ) - .inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as single-message aggregate"), - ) else { + .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to seal block")) else { metrics::inc_block_building_failures(); return; }; - let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = - Vec::with_capacity(single_message_aggregates.len() + 1); - let mut resolve_failed = false; - for sma in &single_message_aggregates { - let mut pubkeys = Vec::new(); - for vid in sma.participant_indices() { - let Some(validator) = validators.get(vid as usize) else { - error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); - resolve_failed = true; - break; - }; - match ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) { - Ok(pk) => pubkeys.push(pk), - Err(err) => { - error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); - resolve_failed = true; - break; - } - } - } - if resolve_failed { - break; - } - merge_inputs.push((pubkeys, sma.proof.clone())); - } - if resolve_failed { - metrics::inc_block_building_failures(); - return; - } - merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - - // Merge yields raw lean-multisig type-2 bytes. Per-component - // participants are rederived at verify time from - // `block.body.attestations[i].aggregation_bits` plus - // `block.proposer_index`, so nothing else needs persisting. - let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { - Ok(bytes) => bytes, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); - metrics::inc_block_building_failures(); - return; - } - }; - let proof = match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { - Ok(p) => p, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); - metrics::inc_block_building_failures(); - return; - } - }; - let signed_block = SignedBlock { - message: block, - proof, - }; - // Stop timing here: the build is done, and the alignment wait below must // not count toward the block-building metric. drop(timing); diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 7cd8f5d9..fcacddc8 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -36,6 +36,13 @@ pub const ATTESTATION_AGGREGATE_COVERAGE_DIFF_DIRECTIONS: &[&str] = &["block_onl pub const BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES: &[&str] = &["select_payloads", "compact", "stf_simulate"]; +/// Phases of sealing a built block (`block_builder::seal_block`), observed on +/// the same histogram: `sign_proposer` (XMSS signature over the block root), +/// `wrap_proposer` (singleton single-message aggregate over that signature), +/// `merge_type2` (merge of every single-message aggregate into the block's +/// multi-message aggregate). +pub const BLOCK_PROPOSAL_SEAL_PHASES: &[&str] = &["sign_proposer", "wrap_proposer", "merge_type2"]; + /// Where a gossip message landed relative to the interval it was due in. /// /// Kept private to the module: unlike [`SyncStatus`] (which the RPC layer @@ -492,15 +499,17 @@ static LEAN_BLOCK_BUILDING_FAILURES_TOTAL: std::sync::LazyLock = register_int_counter!("lean_block_building_failures_total", "Failed block builds").unwrap() }); -// --- Block Proposal Attestation Selection (build_block fixed-point loop) --- +// --- Block Proposal (build_block phases, then the seal in seal_block) --- static LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASE_SECONDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_histogram_vec!( "lean_block_proposal_attestation_build_phase_seconds", - "Phase-level time in block-proposal attestation selection: select_payloads (greedy \ - per-AttestationData proof pick), compact (recursive merge of proofs per \ - AttestationData), stf_simulate (candidate block state transition).", + "Phase-level time in block proposal: select_payloads (greedy per-AttestationData \ + proof pick), compact (recursive merge of proofs per AttestationData), \ + stf_simulate (candidate block state transition), sign_proposer (XMSS block-root \ + signature), wrap_proposer (singleton single-message aggregate over it), \ + merge_type2 (multi-message aggregate merge).", &["phase"], vec![ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0 @@ -1143,8 +1152,8 @@ pub fn inc_block_building_failures() { LEAN_BLOCK_BUILDING_FAILURES_TOTAL.inc(); } -/// Observe the duration of a block-proposal attestation-selection phase. -/// `phase` must be one of [`BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES`]. +/// Observe the duration of a block-proposal phase. `phase` must be one of +/// [`BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES`] or [`BLOCK_PROPOSAL_SEAL_PHASES`]. pub fn observe_block_proposal_phase(phase: &str, elapsed: Duration) { LEAN_BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASE_SECONDS .with_label_values(&[phase]) diff --git a/crates/common/crypto/src/signature.rs b/crates/common/crypto/src/signature.rs index 39515e53..058315b9 100644 --- a/crates/common/crypto/src/signature.rs +++ b/crates/common/crypto/src/signature.rs @@ -8,6 +8,7 @@ use leansig::{ serialization::Serializable, signature::{SignatureScheme, SignatureSchemeSecretKey as _, SigningError}, }; +use rand::{SeedableRng as _, rngs::StdRng}; /// The XMSS signature scheme used for validator signatures. /// @@ -90,6 +91,27 @@ impl ValidatorSecretKey { Ok(Self { inner: sk }) } + /// Derive a key pair deterministically from `seed`, active for epochs + /// `activation_epoch..activation_epoch + num_active_epochs`. + /// + /// The seed fully determines the key, so this exists for tests and + /// benchmarks that need reproducible validators. Real validator keys must + /// come from a cryptographically secure RNG, never from this. Keygen cost + /// scales with `num_active_epochs`. + pub fn generate_from_seed( + seed: u64, + activation_epoch: usize, + num_active_epochs: usize, + ) -> (ValidatorPublicKey, Self) { + let mut rng = StdRng::seed_from_u64(seed); + let (pk, sk) = LeanSignatureScheme::key_gen(&mut rng, activation_epoch, num_active_epochs); + (ValidatorPublicKey { inner: pk }, Self { inner: sk }) + } + + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes() + } + /// Sign a message with this private key. /// /// The slot is used as part of the XMSS signature scheme to track diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 924437b7..671ab06d 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -14,17 +14,24 @@ blocks every run, so two reports differ only where the code differs. ```bash make bench # defaults, mock crypto +BENCH_ARGS="synthetic" make bench # real XMSS/leanVM crypto BENCH_ARGS="synthetic --iterations 50" make bench ``` `make bench` is a thin wrapper. The binary takes the same arguments directly: ```bash +ethlambda benchmark synthetic --num-validators 8 --iterations 10 --key-cache ~/.cache/ethlambda-bench-keys ethlambda benchmark synthetic --mock-crypto --num-validators 8 --iterations 10 ``` -A default mock run finishes in well under a second, which is why CI can afford -to run one on every pull request. +Without `--mock-crypto` the run uses real cryptography end to end: seed-derived +XMSS keys, real attestation signatures aggregated into leanVM type-1 proofs, and +the proposer's real seal (block-root signature, singleton type-1 wrap, type-2 +merge), with every built block imported through the verifying `on_block` path. +A default real run takes a few minutes; `--key-cache` saves the seed-derived +keys so reruns skip key generation. A default mock run finishes in well under a +second, which is why CI can afford to run one on every pull request. | Flag | Default | Meaning | | --- | --- | --- | @@ -32,8 +39,9 @@ to run one on every pull request. | `--warmup-slots` | `8` | Unmeasured slots built first, so measured builds run on a state with realistic historical roots and justifications | | `--iterations` | `10` | Measured builds, one block each | | `--proofs-per-data` | `1` | Aggregates seeded per `AttestationData`, mimicking committee aggregators over disjoint validator subsets | -| `--seed` | `42` | Seed for the validator set; fixes the whole run | -| `--mock-crypto` | off | Placeholder proofs instead of real XMSS/leanVM signatures. **Currently required** — see [Limitations](#limitations) | +| `--seed` | `42` | Seed for the validator set and its XMSS keys; fixes the whole run | +| `--key-cache ` | — | Cache the seed-derived XMSS keys on disk (keyed by leansig revision, seed, validator index and run length). Real crypto only | +| `--mock-crypto` | off | Placeholder proofs instead of real XMSS/leanVM signatures, and no seal. Measures selection, compaction and the state transition only | | `--enable-proposer-aggregation` | off | Mirrors the node flag: collapse same-data proofs via recursive leanVM aggregation | | `--max-attestations-per-block` | `3` | Mirrors the node flag: distinct `AttestationData` per block | | `--format` | `human` | `human` or `json` | @@ -44,26 +52,36 @@ into `jq`. ## What it measures -Each iteration enters `produce_block_with_signatures` — the same function -`BlockChainServer::propose_block` calls — and the harness reports the phases -inside it: +Each iteration enters `produce_block_with_signatures` and then `seal_block` — +the same functions `BlockChainServer::propose_block` calls — and the harness +reports the phases inside them: | Phase | Work | | --- | --- | | `select_payloads` | Choosing which attestations go in the block | -| `compact` | Collapsing or picking among proofs for the same data | +| `compact` | Collapsing or picking among proofs for the same data; with `--enable-proposer-aggregation` this is a real recursive leanVM aggregation | | `stf_simulate` | The state transition that seals `state_root` | -| `overhead` | The rest of the measured span: tick processing, attestation promotion, fork-choice head, pool clone | +| `sign_proposer` | The proposer's XMSS signature over the block root (real crypto only) | +| `wrap_proposer` | Wrapping that signature into a singleton type-1 proof (real crypto only) | +| `merge_type2` | Merging every type-1 proof into the block's type-2 proof (real crypto only) | +| `overhead` | The rest of the measured span: tick processing, attestation promotion, fork-choice head, pool clone, pubkey resolution | | `wall` | The whole span | `overhead` is `wall` minus the sum of the phases, so the columns add up by -construction. +construction. In mock mode there is nothing to sign with, so the seal is skipped +and its three phases are absent. Deliberately **outside** the measured span, matching the boundary of the node's own `lean_block_building_time_seconds` metric: gossip publish, the slot-alignment sleep, and importing the block that was just built. The import still happens between iterations — otherwise every iteration would build on the -same head and `process_slots` would get more expensive as the run went on. +same head and `process_slots` would get more expensive as the run went on. Two +such costs are reported anyway, because they are real crypto worth watching: + +| Column | Work | +| --- | --- | +| `aggregate` | Producing the slot's pool entries: every validator's attestation signature plus their type-1 aggregation. Aggregator-side work a proposer never does; zero in mock mode | +| `import` | Importing the built block; in real mode this includes verifying its type-2 proof | Phase times come from the sample sums of the existing `lean_block_proposal_attestation_build_phase_seconds` histogram, read before and @@ -76,18 +94,24 @@ otherwise, because a mis-attributed report is worse than no report. ## Reading a report ``` -Block-building benchmark — synthetic workload (mock crypto) - validators=8 warmup_slots=8 iterations=10 proofs_per_data=1 seed=42 +Block-building benchmark — synthetic workload (real crypto) + validators=2 warmup_slots=1 iterations=2 proofs_per_data=1 seed=42 enable_proposer_aggregation=false max_attestations_per_block=3 ethlambda/v0.1.0/aarch64-apple-darwin/rustc-v1.97.1 leansig=15cbdd43 leanvm=e2592df4 os=macos arch=aarch64 threads=14 - iter compact select_payloads stf_simulate overhead wall root - 1 0.000ms 0.002ms 0.015ms 0.068ms 0.085ms 0x7282cc99 - ... + iter compact merge_type2 select_payloads sign_proposer stf_simulate wrap_proposer overhead wall aggregate import root + 1 0.001ms 550.641ms 0.007ms 0.461ms 0.011ms 65.127ms 0.103ms 616.350ms 103.548ms 19.205ms 0x77465b33 + 2 0.001ms 1175.326ms 0.015ms 2.024ms 0.015ms 73.124ms 0.119ms 1250.623ms 94.691ms 20.472ms 0xf7e48c73 phase count min mean p50 p90 max - select_payloads 10 0.002ms 0.002ms 0.002ms 0.003ms 0.003ms + compact 2 0.001ms 0.001ms 0.001ms 0.001ms 0.001ms + merge_type2 2 550.641ms 862.983ms 1175.326ms 1175.326ms 1175.326ms ... + wall 2 616.350ms 933.487ms 1250.623ms 1250.623ms 1250.623ms + + outside the measured span: + aggregate 2 94.691ms 99.119ms 103.548ms 103.548ms 103.548ms + import 2 19.205ms 19.838ms 20.472ms 20.472ms 20.472ms ``` Every measured iteration gets its own row, and the summary follows below it. @@ -115,20 +139,22 @@ they *cannot* be compared: - `leansig` and `leanvm` are the resolved revisions the binary was built against, read from `Cargo.lock` at build time. leanSig tracks a moving branch and leanVM performs the signature aggregation, so either one moving changes - the measured crypto. + the measured crypto. Real-mode roots also depend on the seed-derived keys, so + the same seed on the same leansig revision reproduces the same signatures and + the same roots. - `os`, `arch` and `threads` change results across machines. Two reports that disagree on any of those are not measuring the same thing. ## Limitations -- **`--mock-crypto` is required.** Real XMSS/leanVM pools are not wired up yet, - so the run measures selection, compaction and the state transition — not - signing or aggregation. -- **The seal phase is not measured.** Signing, type-1 wrapping and type-2 - merging happen after the measured span and are not reported. - **Synthetic workloads only.** Replaying a real datadir is not implemented, so results reflect a synthetic chain rather than a deep production state. +- **Short-lived keys.** Real-mode XMSS keys are generated for exactly the slots + the run signs, so key generation is cheap but the OTS window advancement a + long-lived validator key performs every 65,536 slots is never exercised. +- **Mock mode skips the seal.** Without keys there is nothing to sign, so the + three seal phases only appear in real runs. ## In CI diff --git a/docs/metrics.md b/docs/metrics.md index f0a70c17..b6183ff0 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -39,7 +39,7 @@ The exposed metrics follow [the leanMetrics specification](https://github.com/le | `lean_block_building_time_seconds` | Histogram | Time taken to build a block | On block production | | 0.1, 0.25, 0.5, 0.75, 1, 2, 4, 8 | ✅ | | `lean_block_building_success_total` | Counter | Successful block builds | On block production | | | ✅ | | `lean_block_building_failures_total` | Counter | Failed block builds (error building the block, signing the block root, or processing it locally) | On block production failure | | | ✅ | -| `lean_block_proposal_attestation_build_phase_seconds` | Histogram | Phase-level time in block-proposal attestation selection | On block production | phase=select_payloads,compact,stf_simulate | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8 | ✅ | +| `lean_block_proposal_attestation_build_phase_seconds` | Histogram | Phase-level time in block proposal: attestation selection, compaction, state transition, then the seal (proposer signature, type-1 wrap, type-2 merge) | On block production | phase=select_payloads,compact,stf_simulate,sign_proposer,wrap_proposer,merge_type2 | 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8 | ✅ | | `lean_block_proposal_attestation_builds_total` | Counter | Attestations selected during block-proposal selection (one per selection-loop round that picks an `AttestationData`) | On each attestation selection | | | ✅ | | `lean_block_proposal_child_payloads_consumed_total` | Counter | Child aggregated payloads selected during greedy proof picking (before compaction) | On block production | | | ✅ | | `lean_block_proposal_attestation_data_selected` | Histogram | Distinct `AttestationData` entries in the proposal block body | On block production | | 0, 1, 2, 4, 8, 16, 32 | ✅ |