From a7988d036545432724fabf1d805204f44248056e Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 8 Sep 2026 15:47:50 -0300 Subject: [PATCH 1/3] Extract the proposer's block seal into block_builder::seal_block and time its phases The sign, wrap and merge steps that turn a built block into a SignedBlock lived only inline in BlockChainServer::propose_block, so nothing outside the running node could exercise or time them. seal_block is that code lifted verbatim behind a SealError enum; propose_block calls it and keeps its logging and failure counter on Err. Each step is observed on the block-proposal phase histogram under three new labels (sign_proposer, wrap_proposer, merge_type2) listed in BLOCK_PROPOSAL_SEAL_PHASES, alongside the existing build phases. Together they cover the same span as lean_block_building_time_seconds, so the per-phase breakdown now accounts for the whole build. --- crates/blockchain/src/block_builder.rs | 118 +++++++++++++++++++++++- crates/blockchain/src/lib.rs | 119 +++---------------------- crates/blockchain/src/metrics.rs | 19 ++-- docs/metrics.md | 2 +- 4 files changed, 141 insertions(+), 117 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index f7f4f25e..94dd8a98 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::{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, SignedBlock, + SingleMessageAggregate, + }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, state::{JustifiedSlots, State}, }; 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`. /// @@ -887,6 +898,107 @@ fn trace_skipped_attestation(reason: &'static str, att: &AttestationData, data_r ); } +/// 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(String), + #[error("failed to decode proposer signature bytes: {0}")] + ProposerSignature(String), + #[error("participant {0} out of range while resolving pubkeys")] + ParticipantOutOfRange(u64), + #[error("failed to decode attestation pubkey of validator {0}: {1}")] + ParticipantPubkey(u64, String), + #[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(String), +} + +/// 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`. 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: &[SingleMessageAggregate], +) -> 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(|err| SealError::ProposerPubkey(err.0))?; + let proposer_validator_signature = ValidatorSignature::from_bytes(&proposer_signature) + .map_err(|err| SealError::ProposerSignature(err.0))?; + + 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 = sma + .participant_indices() + .map(|vid| { + let validator = validators + .get(vid as usize) + .ok_or(SealError::ParticipantOutOfRange(vid))?; + ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) + .map_err(|err| SealError::ParticipantPubkey(vid, err.0)) + }) + .collect::, _>>()?; + merge_inputs.push((pubkeys, sma.proof.clone())); + } + 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()) + .map_err(|err| SealError::Decode(err.to_string()))?; + 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..5db7dc77 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,22 @@ 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 seal_result = 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")); + let Ok(signed_block) = seal_result 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..6edb2cd4 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 @@ -498,9 +505,11 @@ 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/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 | ✅ | From 56f261940ab3fd5367be684b0ec885565439790e Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 8 Sep 2026 15:47:50 -0300 Subject: [PATCH 2/3] Benchmark block building with real XMSS and leanVM crypto `ethlambda benchmark synthetic` no longer requires --mock-crypto. Without it the run derives an attestation and a proposal XMSS key per validator from the seed (keygen is sized to the slots the run signs, so it costs seconds, and --key-cache stores the keys for reruns), puts the real pubkeys in the synthetic genesis, has every validator sign each slot's attestation data through the production KeyManager, aggregates each participant group into a real leanVM type-1 proof, and seals the built block with seal_block. The measured span is build plus seal, exactly the node's lean_block_building_time_seconds boundary, and the three seal phases appear as columns. Every sealed block is imported through the verifying on_block, so an invalid proof fails the run rather than producing a report about invalid blocks. Two costs outside the span are reported anyway, since they are real crypto: `aggregate` (the aggregator-side signing and type-1 aggregation that produces the slot's pool entries) and `import` (which now includes type-2 verification). Same seed and parameters still reproduce the same block roots: XMSS signing is deterministic and the keys are seed-derived. --mock-crypto keeps the sub-second path CI runs; it skips the seal, so its report carries only the build phases. Verified locally with two validators: keys generated in 6.8s, ~0.1s per slot of type-1 aggregation, 0.5-1.3s type-2 merges, 20ms verified imports, identical roots across a rerun that loaded the keys from the cache, and a real recursive compaction under --enable-proposer-aggregation --proofs-per-data 2. --- Cargo.lock | 2 + bin/ethlambda/Cargo.toml | 4 + bin/ethlambda/src/benchmark/corpus.rs | 190 ++++++++++++++++---- bin/ethlambda/src/benchmark/keys.rs | 238 ++++++++++++++++++++++++++ bin/ethlambda/src/benchmark/mod.rs | 160 ++++++++++++----- bin/ethlambda/src/benchmark/report.rs | 38 +++- docs/benchmarking.md | 72 +++++--- 7 files changed, 607 insertions(+), 97 deletions(-) create mode 100644 bin/ethlambda/src/benchmark/keys.rs diff --git a/Cargo.lock b/Cargo.lock index dacda8d5..56d264c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1927,9 +1927,11 @@ dependencies = [ "ethlambda-types", "eyre", "hex", + "leansig", "libc", "libssz", "libssz-types", + "rand 0.10.1", "reqwest", "serde", "serde_json", diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 591490ca..0dc110be 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -28,6 +28,10 @@ ethlambda-types.workspace = true ethlambda-rpc.workspace = true ethlambda-storage.workspace = true +# Seeded XMSS keygen for the real-crypto benchmark corpus. +leansig.workspace = true +rand.workspace = true + libssz.workspace = true libssz-types.workspace = true diff --git a/bin/ethlambda/src/benchmark/corpus.rs b/bin/ethlambda/src/benchmark/corpus.rs index bc007c54..eead38fc 100644 --- a/bin/ethlambda/src/benchmark/corpus.rs +++ b/bin/ethlambda/src/benchmark/corpus.rs @@ -2,47 +2,84 @@ //! per-slot attestation-pool seeding. use std::sync::Arc; +use std::time::Instant; +use ethlambda_blockchain::key_manager::KeyManager; use ethlambda_blockchain::store::produce_attestation_data; +use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ - attestation::{AggregationBits, HashedAttestationData}, + attestation::{AggregationBits, HashedAttestationData, validator_indices}, block::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; +/// How the corpus produces pool entries and genesis pubkeys. +pub(crate) enum CryptoMode { + /// Empty placeholder proofs and placeholder pubkey bytes. No code path + /// decodes either: verification is skipped and best-proof compaction never + /// resolves pubkeys. + Mock, + /// Real XMSS signatures aggregated into real leanVM type-1 proofs, over the + /// genesis pubkeys of the seeded key set. + Real { + genesis_pubkeys: Vec<(ValidatorPubkeyBytes, ValidatorPubkeyBytes)>, + attestation_pubkeys: Vec, + }, +} + +/// 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 +95,74 @@ 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 through + /// `key_manager` 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, store: &mut Store, + key_manager: &mut KeyManager, 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 groups = participant_groups(self.num_validators, self.proofs_per_data); + + let aggregate_start = Instant::now(); + let entries = match &self.crypto { + CryptoMode::Mock => groups + .into_iter() + .map(|participants| { + ( + HashedAttestationData::new(data.clone()), + SingleMessageAggregate::empty(participants), + ) + }) + .collect(), + CryptoMode::Real { + attestation_pubkeys, + .. + } => { + 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 indices: Vec = validator_indices(&participants).collect(); + let mut pubkeys = Vec::with_capacity(indices.len()); + let mut signatures = Vec::with_capacity(indices.len()); + for &validator in &indices { + let bytes = key_manager + .sign_attestation(validator, &data) + .wrap_err_with(|| { + format!("validator {validator} failed to sign slot {slot}") + })?; + let signature = ValidatorSignature::from_bytes(&bytes) + .map_err(|err| eyre::eyre!("signature bytes: {}", err.0))?; + pubkeys.push(attestation_pubkeys[validator as usize].clone()); + signatures.push(signature); + } + let proof = + ethlambda_crypto::aggregate_signatures(pubkeys, signatures, &message, slot) + .wrap_err_with(|| { + format!( + "type-1 aggregation of {} signatures failed at slot {slot}", + indices.len() + ) + })?; + entries.push(( + HashedAttestationData::new(data.clone()), + SingleMessageAggregate::new(participants, proof), + )); + } + entries + } + }; + let aggregate_seconds = match self.crypto { + CryptoMode::Mock => 0.0, + CryptoMode::Real { .. } => aggregate_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 +174,10 @@ 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, + }) } } @@ -135,7 +222,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 +251,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 corpus = SyntheticCorpus::new( + 2, + 1, + CryptoMode::Real { + genesis_pubkeys: keys.genesis_pubkeys(), + attestation_pubkeys: keys.attestation_pubkeys().unwrap(), + }, + ); + let mut key_manager = keys.into_key_manager().unwrap(); + let mut store = corpus.genesis_store(1); + let outcome = corpus.seed_pool(&mut store, &mut key_manager, 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 pubkeys = proof + .participant_indices() + .map(|index| { + let validator = &store.head_state().validators[index as usize]; + ValidatorPublicKey::from_bytes(&validator.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..bf81c8de --- /dev/null +++ b/bin/ethlambda/src/benchmark/keys.rs @@ -0,0 +1,238 @@ +//! 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), 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::{LeanSignatureScheme, ValidatorPublicKey, ValidatorSecretKey}; +use ethlambda_types::state::ValidatorPubkeyBytes; +use eyre::WrapErr as _; +use leansig::{serialization::Serializable as _, signature::SignatureScheme as _}; +use rand::{SeedableRng as _, rngs::StdRng}; + +const PUBKEY_LEN: usize = std::mem::size_of::(); + +#[derive(Debug, Clone, Copy)] +enum Role { + Attestation, + Proposal, +} + +impl Role { + fn tag(self) -> &'static str { + match self { + Role::Attestation => "attestation", + Role::Proposal => "proposal", + } + } +} + +/// One validator's generated key material: pubkeys as stored in the genesis +/// state, secrets as leansig serialized bytes. +struct ValidatorKeys { + attestation_pubkey: ValidatorPubkeyBytes, + proposal_pubkey: ValidatorPubkeyBytes, + attestation_secret: Vec, + proposal_secret: Vec, +} + +pub(crate) struct KeySet { + validators: Vec, +} + +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"))?; + + let start = Instant::now(); + let mut generated = 0usize; + let mut validators = Vec::with_capacity(num_validators as usize); + for index in 0..num_validators { + let mut key = |role: Role| -> eyre::Result<(ValidatorPubkeyBytes, Vec)> { + let file = cache.map(|dir| { + dir.join(format!( + "xmss-{}-seed{seed}-v{index}-{}-w{num_slots}.bin", + env!("ETHLAMBDA_LEANSIG_REV"), + role.tag() + )) + }); + if let Some(file) = &file + && file.is_file() + { + return load_cached(file); + } + let (pubkey, secret) = generate_key(seed, index, role, num_active_epochs)?; + generated += 1; + if let Some(file) = &file { + let mut bytes = pubkey.to_vec(); + bytes.extend_from_slice(&secret); + std::fs::write(file, bytes).wrap_err_with(|| { + format!("failed to write cached key {}", file.display()) + })?; + } + Ok((pubkey, secret)) + }; + let (attestation_pubkey, attestation_secret) = key(Role::Attestation)?; + let (proposal_pubkey, proposal_secret) = key(Role::Proposal)?; + validators.push(ValidatorKeys { + attestation_pubkey, + proposal_pubkey, + attestation_secret, + proposal_secret, + }); + } + eprintln!( + "validator keys ready in {:.1}s ({generated} generated, {} loaded from cache)", + start.elapsed().as_secs_f64(), + validators.len() * 2 - generated, + ); + Ok(Self { validators }) + } + + /// `(attestation_pubkey, proposal_pubkey)` per validator, for the genesis state. + pub(crate) fn genesis_pubkeys(&self) -> Vec<(ValidatorPubkeyBytes, ValidatorPubkeyBytes)> { + self.validators + .iter() + .map(|keys| (keys.attestation_pubkey, keys.proposal_pubkey)) + .collect() + } + + /// Decoded attestation pubkeys, indexed by validator, for type-1 aggregation. + pub(crate) fn attestation_pubkeys(&self) -> eyre::Result> { + self.validators + .iter() + .enumerate() + .map(|(index, keys)| { + ValidatorPublicKey::from_bytes(&keys.attestation_pubkey) + .map_err(|err| eyre::eyre!("validator {index} attestation pubkey: {}", err.0)) + }) + .collect() + } + + /// Build the production `KeyManager` over these keys, so the benchmark signs + /// through exactly the code path the node uses. + pub(crate) fn into_key_manager(self) -> eyre::Result { + let mut keys = HashMap::with_capacity(self.validators.len()); + for (index, validator) in self.validators.into_iter().enumerate() { + let decode = |bytes: &[u8], role: Role| { + ValidatorSecretKey::from_bytes(bytes).map_err(|err| { + eyre::eyre!( + "validator {index} {} secret key does not decode ({}); \ + if --key-cache was used, delete the cache directory and rerun", + role.tag(), + err.0 + ) + }) + }; + keys.insert( + index as u64, + ValidatorKeyPair { + attestation_key: decode(&validator.attestation_secret, Role::Attestation)?, + proposal_key: decode(&validator.proposal_secret, Role::Proposal)?, + }, + ); + } + Ok(KeyManager::new(keys)) + } +} + +/// Deterministic keygen: the RNG is seeded from `(seed, index, role)` so every +/// key is distinct and reproducible. +fn generate_key( + seed: u64, + index: u64, + role: Role, + num_active_epochs: usize, +) -> eyre::Result<(ValidatorPubkeyBytes, Vec)> { + let role_bit = match role { + Role::Attestation => 0, + Role::Proposal => 1, + }; + let mut rng = StdRng::seed_from_u64(seed ^ (index << 1 | role_bit).rotate_left(32)); + let (pubkey, secret) = LeanSignatureScheme::key_gen(&mut rng, 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() + ) + })?; + Ok((pubkey, secret.to_bytes())) +} + +/// A cache entry is the pubkey bytes followed by the serialized secret key. +fn load_cached(file: &Path) -> eyre::Result<(ValidatorPubkeyBytes, Vec)> { + 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 pubkey: ValidatorPubkeyBytes = pubkey.try_into().expect("split at PUBKEY_LEN"); + Ok((pubkey, secret.to_vec())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_are_deterministic_per_seed_and_distinct_per_role() { + let (a_pub, a_sec) = generate_key(7, 3, Role::Attestation, 2).unwrap(); + let (b_pub, b_sec) = generate_key(7, 3, Role::Attestation, 2).unwrap(); + assert_eq!(a_pub, b_pub); + assert_eq!(a_sec, b_sec); + let (p_pub, _) = generate_key(7, 3, Role::Proposal, 2).unwrap(); + assert_ne!(a_pub, p_pub); + let (s_pub, _) = generate_key(8, 3, Role::Attestation, 2).unwrap(); + assert_ne!(a_pub, s_pub); + } + + #[test] + fn cache_round_trips_and_decodes() { + let dir = std::env::temp_dir().join(format!( + "ethlambda-bench-keys-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let first = KeySet::generate(11, 1, 2, Some(&dir)).unwrap(); + let second = KeySet::generate(11, 1, 2, Some(&dir)).unwrap(); + assert_eq!(first.genesis_pubkeys(), second.genesis_pubkeys()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + let mut key_manager = second.into_key_manager().unwrap(); + assert_eq!(key_manager.validator_ids(), vec![0]); + key_manager + .sign_block_root(0, 1, ðlambda_types::primitives::H256::ZERO) + .expect("cached key signs within its window"); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/bin/ethlambda/src/benchmark/mod.rs b/bin/ethlambda/src/benchmark/mod.rs index 869ddcce..7cbc25f4 100644 --- a/bin/ethlambda/src/benchmark/mod.rs +++ b/bin/ethlambda/src/benchmark/mod.rs @@ -1,29 +1,38 @@ //! 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}; 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::block_builder::{ProposerConfig, 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::{ + on_block, on_block_without_verification, 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 +72,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 +118,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,22 +159,42 @@ 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, mut key_manager) = if common.mock_crypto { + (CryptoMode::Mock, KeyManager::new(HashMap::new())) + } else { + let keys = keys::KeySet::generate( + options.seed, + options.num_validators, + total_slots + 1, + options.key_cache.as_deref(), + )?; + let crypto = CryptoMode::Real { + genesis_pubkeys: keys.genesis_pubkeys(), + attestation_pubkeys: keys.attestation_pubkeys()?, + }; + (crypto, keys.into_key_manager()?) + }; + let 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 store, + &mut key_manager, slot, options.num_validators, proposer_config, + common.mock_crypto, )?; let measured = slot > options.warmup_slots; log_progress(slot, total_slots, measured, &sample); @@ -201,14 +232,17 @@ 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 in real mode 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 @@ -216,15 +250,20 @@ fn log_progress(slot: u64, total_slots: u64, measured: bool, sample: &Sample) { fn build_one_slot( corpus: &corpus::SyntheticCorpus, store: &mut Store, + key_manager: &mut KeyManager, slot: u64, num_validators: u64, proposer_config: ProposerConfig, + mock_crypto: bool, ) -> eyre::Result { // 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, key_manager, 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 +273,39 @@ 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(); + // The seal needs real signatures, so mock mode stops at the built block and + // imports it with an empty proof, the way the fork-choice spec tests do. + let signed_block = if mock_crypto { + SignedBlock { + message: block, + proof: MultiMessageAggregate::default(), + } + } else { + let head_state = store.head_state(); + seal_block(&head_state, key_manager, 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(expected_phases(mock_crypto))?; - 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) - .wrap_err_with(|| format!("importing the built block failed at slot {slot}"))?; + // index. Real mode imports through `on_block`, so the merged proof is + // verified and a bad seal fails the run instead of producing a report + // about invalid blocks. + let import_start = Instant::now(); + if mock_crypto { + on_block_without_verification(store, signed_block) + } else { + on_block(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 +322,30 @@ 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, }) } +/// The phases one slot observes: the build phases always, plus the seal phases +/// when the seal runs. +fn expected_phases(mock_crypto: bool) -> impl Iterator { + let seal = if mock_crypto { + &[][..] + } else { + BLOCK_PROPOSAL_SEAL_PHASES + }; + BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES + .iter() + .chain(seal) + .copied() +} + 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 +360,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..112b96da 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)] @@ -132,6 +142,18 @@ impl Report { .map(|sample| sample.wall_seconds) .collect::>(), ); + let aggregate = stats( + &samples + .iter() + .map(|sample| sample.aggregate_seconds) + .collect::>(), + ); + let import = stats( + &samples + .iter() + .map(|sample| sample.import_seconds) + .collect::>(), + ); if wall.cv > CV_WARN_THRESHOLD { eprintln!( @@ -151,6 +173,8 @@ impl Report { phases, overhead, wall, + aggregate, + import, }, } } @@ -205,7 +229,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 +243,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 +263,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/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 From 12f1f6424ad5b772ede564c4d6a77e196fe3cb88 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 8 Sep 2026 19:32:15 -0300 Subject: [PATCH 3/3] Simplify the real-crypto benchmark: one owner for the crypto mode The harness encoded the mock/real decision three times: a CryptoMode on the corpus, a mock_crypto bool threaded through build_one_slot and expected_phases, and an empty KeyManager passed in mock mode only so the signature could be shared. The KeyManager now lives inside CryptoMode::Real, and the corpus owns every step the mode decides: seed_pool, seal, import, and which phases a slot must observe. build_one_slot loses two parameters and every mode branch. keys.rs decodes secrets at the boundary instead of carrying bytes and decoding later, exposes the genesis pubkeys and the KeyManager directly, drops the decoded attestation-pubkey array (seed_pool reads pubkeys from the state, as production does), and generates keys in parallel with rayon since each is independent and deterministic. The seeded keygen idiom moves into ValidatorSecretKey::generate_from_seed so the binary no longer depends on leansig or rand. seal_block takes the aggregates by value so proof bytes move into the merge instead of being copied on every proposal, resolves participant pubkeys through a helper shared with the compaction path in the same file, and carries typed error sources instead of strings. Block roots from the two-validator real run are byte-identical to the previous commit's. --- Cargo.lock | 4 +- bin/ethlambda/Cargo.toml | 6 +- bin/ethlambda/src/benchmark/corpus.rs | 162 +++++++++++------ bin/ethlambda/src/benchmark/keys.rs | 236 +++++++++++-------------- bin/ethlambda/src/benchmark/mod.rs | 84 +++------ bin/ethlambda/src/benchmark/report.rs | 30 +--- crates/blockchain/src/block_builder.rs | 86 +++++---- crates/blockchain/src/lib.rs | 7 +- crates/blockchain/src/metrics.rs | 2 +- crates/common/crypto/src/signature.rs | 22 +++ 10 files changed, 308 insertions(+), 331 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56d264c8..586c4b3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1927,15 +1927,15 @@ dependencies = [ "ethlambda-types", "eyre", "hex", - "leansig", "libc", "libssz", "libssz-types", - "rand 0.10.1", + "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 0dc110be..206766b1 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -28,9 +28,8 @@ ethlambda-types.workspace = true ethlambda-rpc.workspace = true ethlambda-storage.workspace = true -# Seeded XMSS keygen for the real-crypto benchmark corpus. -leansig.workspace = true -rand.workspace = true +# Parallel XMSS keygen for the real-crypto benchmark corpus. +rayon.workspace = true libssz.workspace = true libssz-types.workspace = true @@ -60,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 eead38fc..1a97c211 100644 --- a/bin/ethlambda/src/benchmark/corpus.rs +++ b/bin/ethlambda/src/benchmark/corpus.rs @@ -1,16 +1,23 @@ -//! 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::block_builder::seal_block; use ethlambda_blockchain::key_manager::KeyManager; -use ethlambda_blockchain::store::produce_attestation_data; +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, validator_indices}, - block::SingleMessageAggregate, + block::{Block, MultiMessageAggregate, SignedBlock, SingleMessageAggregate}, constants::DEFAULT_MILLISECONDS_PER_SLOT, primitives::HashTreeRoot as _, state::{State, Validator, ValidatorPubkeyBytes}, @@ -22,17 +29,18 @@ use eyre::WrapErr as _; /// clock, so runs are reproducible at any time of day. const GENESIS_TIME: u64 = 1_700_000_000; -/// How the corpus produces pool entries and genesis pubkeys. +/// Everything that differs between a mock and a real-crypto run. pub(crate) enum CryptoMode { - /// Empty placeholder proofs and placeholder pubkey bytes. No code path - /// decodes either: verification is skipped and best-proof compaction never - /// resolves pubkeys. + /// 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, over the - /// genesis pubkeys of the seeded key set. + /// 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)>, - attestation_pubkeys: Vec, + /// Signs attestations for the corpus and block roots for the seal. + key_manager: KeyManager, }, } @@ -95,74 +103,69 @@ impl SyntheticCorpus { /// /// Mirrors what committee aggregators gossip during a slot: several /// aggregates for the same `AttestationData`, each covering a validator - /// subset. In real mode each subset's validators sign the data through - /// `key_manager` 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. + /// 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, - key_manager: &mut KeyManager, attestation_slot: u64, ) -> eyre::Result { let data = produce_attestation_data(store, attestation_slot); + let hashed = HashedAttestationData::new(data.clone()); let groups = participant_groups(self.num_validators, self.proofs_per_data); - let aggregate_start = Instant::now(); - let entries = match &self.crypto { - CryptoMode::Mock => groups - .into_iter() - .map(|participants| { - ( - HashedAttestationData::new(data.clone()), - SingleMessageAggregate::empty(participants), - ) - }) - .collect(), - CryptoMode::Real { - attestation_pubkeys, - .. - } => { + 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 indices: Vec = validator_indices(&participants).collect(); - let mut pubkeys = Vec::with_capacity(indices.len()); - let mut signatures = Vec::with_capacity(indices.len()); - for &validator in &indices { - let bytes = key_manager + 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}") })?; - let signature = ValidatorSignature::from_bytes(&bytes) - .map_err(|err| eyre::eyre!("signature bytes: {}", err.0))?; - pubkeys.push(attestation_pubkeys[validator as usize].clone()); - signatures.push(signature); + 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 {} signatures failed at slot {slot}", - indices.len() + "type-1 aggregation of {count} signatures failed at slot {slot}" ) })?; entries.push(( - HashedAttestationData::new(data.clone()), + hashed.clone(), SingleMessageAggregate::new(participants, proof), )); } - entries + (entries, start.elapsed().as_secs_f64()) } }; - let aggregate_seconds = match self.crypto { - CryptoMode::Mock => 0.0, - CryptoMode::Real { .. } => aggregate_start.elapsed().as_secs_f64(), - }; store.insert_new_aggregated_payloads_batch(entries); // The pending pool evicts whole data-root entries FIFO once its proof @@ -179,6 +182,49 @@ impl SyntheticCorpus { 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() + } } /// Partition validators 0..num_validators into `groups` disjoint bitfields, @@ -260,17 +306,16 @@ mod tests { fn real_seeding_produces_verifiable_proofs() { use crate::benchmark::keys::KeySet; let keys = KeySet::generate(1, 2, 2, None).unwrap(); - let corpus = SyntheticCorpus::new( + let mut corpus = SyntheticCorpus::new( 2, 1, CryptoMode::Real { - genesis_pubkeys: keys.genesis_pubkeys(), - attestation_pubkeys: keys.attestation_pubkeys().unwrap(), + genesis_pubkeys: keys.genesis_pubkeys, + key_manager: keys.key_manager, }, ); - let mut key_manager = keys.into_key_manager().unwrap(); let mut store = corpus.genesis_store(1); - let outcome = corpus.seed_pool(&mut store, &mut key_manager, 0).unwrap(); + let outcome = corpus.seed_pool(&mut store, 0).unwrap(); assert_eq!(outcome.pool_entries, 1); assert!(outcome.aggregate_seconds > 0.0); @@ -282,11 +327,12 @@ mod tests { .next() .expect("one seeded entry"); let proof = &proofs[0]; + let validators = store.head_state().validators; let pubkeys = proof .participant_indices() .map(|index| { - let validator = &store.head_state().validators[index as usize]; - ValidatorPublicKey::from_bytes(&validator.attestation_pubkey).unwrap() + ValidatorPublicKey::from_bytes(&validators[index as usize].attestation_pubkey) + .unwrap() }) .collect(); ethlambda_crypto::verify_aggregated_signature( diff --git a/bin/ethlambda/src/benchmark/keys.rs b/bin/ethlambda/src/benchmark/keys.rs index bf81c8de..c857b7ab 100644 --- a/bin/ethlambda/src/benchmark/keys.rs +++ b/bin/ethlambda/src/benchmark/keys.rs @@ -4,25 +4,26 @@ //! 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), and `--key-cache` stores them so reruns skip keygen. +//! 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::{LeanSignatureScheme, ValidatorPublicKey, ValidatorSecretKey}; +use ethlambda_crypto::signature::ValidatorSecretKey; use ethlambda_types::state::ValidatorPubkeyBytes; use eyre::WrapErr as _; -use leansig::{serialization::Serializable as _, signature::SignatureScheme as _}; -use rand::{SeedableRng as _, rngs::StdRng}; +use rayon::prelude::*; -const PUBKEY_LEN: usize = std::mem::size_of::(); +const PUBKEY_LEN: usize = size_of::(); #[derive(Debug, Clone, Copy)] +#[repr(u64)] enum Role { - Attestation, - Proposal, + Attestation = 0, + Proposal = 1, } impl Role { @@ -34,17 +35,18 @@ impl Role { } } -/// One validator's generated key material: pubkeys as stored in the genesis -/// state, secrets as leansig serialized bytes. -struct ValidatorKeys { - attestation_pubkey: ValidatorPubkeyBytes, - proposal_pubkey: ValidatorPubkeyBytes, - attestation_secret: Vec, - proposal_secret: Vec, +struct Key { + pubkey: ValidatorPubkeyBytes, + secret: ValidatorSecretKey, + cached: bool, } pub(crate) struct KeySet { - validators: Vec, + /// `(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 { @@ -69,123 +71,92 @@ impl KeySet { .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 mut generated = 0usize; - let mut validators = Vec::with_capacity(num_validators as usize); - for index in 0..num_validators { - let mut key = |role: Role| -> eyre::Result<(ValidatorPubkeyBytes, Vec)> { - let file = cache.map(|dir| { - dir.join(format!( - "xmss-{}-seed{seed}-v{index}-{}-w{num_slots}.bin", - env!("ETHLAMBDA_LEANSIG_REV"), - role.tag() - )) - }); - if let Some(file) = &file - && file.is_file() - { - return load_cached(file); - } - let (pubkey, secret) = generate_key(seed, index, role, num_active_epochs)?; - generated += 1; - if let Some(file) = &file { - let mut bytes = pubkey.to_vec(); - bytes.extend_from_slice(&secret); - std::fs::write(file, bytes).wrap_err_with(|| { - format!("failed to write cached key {}", file.display()) - })?; - } - Ok((pubkey, secret)) - }; - let (attestation_pubkey, attestation_secret) = key(Role::Attestation)?; - let (proposal_pubkey, proposal_secret) = key(Role::Proposal)?; - validators.push(ValidatorKeys { - attestation_pubkey, - proposal_pubkey, - attestation_secret, - proposal_secret, - }); - } + 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} generated, {} loaded from cache)", + "validator keys ready in {:.1}s ({} generated, {cached} loaded from cache)", start.elapsed().as_secs_f64(), - validators.len() * 2 - generated, + keys.len() - cached, ); - Ok(Self { validators }) - } - /// `(attestation_pubkey, proposal_pubkey)` per validator, for the genesis state. - pub(crate) fn genesis_pubkeys(&self) -> Vec<(ValidatorPubkeyBytes, ValidatorPubkeyBytes)> { - self.validators - .iter() - .map(|keys| (keys.attestation_pubkey, keys.proposal_pubkey)) - .collect() - } - - /// Decoded attestation pubkeys, indexed by validator, for type-1 aggregation. - pub(crate) fn attestation_pubkeys(&self) -> eyre::Result> { - self.validators - .iter() - .enumerate() - .map(|(index, keys)| { - ValidatorPublicKey::from_bytes(&keys.attestation_pubkey) - .map_err(|err| eyre::eyre!("validator {index} attestation pubkey: {}", err.0)) - }) - .collect() - } - - /// Build the production `KeyManager` over these keys, so the benchmark signs - /// through exactly the code path the node uses. - pub(crate) fn into_key_manager(self) -> eyre::Result { - let mut keys = HashMap::with_capacity(self.validators.len()); - for (index, validator) in self.validators.into_iter().enumerate() { - let decode = |bytes: &[u8], role: Role| { - ValidatorSecretKey::from_bytes(bytes).map_err(|err| { - eyre::eyre!( - "validator {index} {} secret key does not decode ({}); \ - if --key-cache was used, delete the cache directory and rerun", - role.tag(), - err.0 - ) - }) + 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"); }; - keys.insert( - index as u64, + genesis_pubkeys.push((attestation.pubkey, proposal.pubkey)); + pairs.insert( + index, ValidatorKeyPair { - attestation_key: decode(&validator.attestation_secret, Role::Attestation)?, - proposal_key: decode(&validator.proposal_secret, Role::Proposal)?, + attestation_key: attestation.secret, + proposal_key: proposal.secret, }, ); } - Ok(KeyManager::new(keys)) + Ok(Self { + genesis_pubkeys, + key_manager: KeyManager::new(pairs), + }) } } -/// Deterministic keygen: the RNG is seeded from `(seed, index, role)` so every -/// key is distinct and reproducible. -fn generate_key( +/// 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, -) -> eyre::Result<(ValidatorPubkeyBytes, Vec)> { - let role_bit = match role { - Role::Attestation => 0, - Role::Proposal => 1, - }; - let mut rng = StdRng::seed_from_u64(seed ^ (index << 1 | role_bit).rotate_left(32)); - let (pubkey, secret) = LeanSignatureScheme::key_gen(&mut rng, 0, num_active_epochs); + 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() ) })?; - Ok((pubkey, secret.to_bytes())) + 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<(ValidatorPubkeyBytes, Vec)> { +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!( @@ -194,8 +165,17 @@ fn load_cached(file: &Path) -> eyre::Result<(ValidatorPubkeyBytes, Vec)> { file.display() ); let (pubkey, secret) = bytes.split_at(PUBKEY_LEN); - let pubkey: ValidatorPubkeyBytes = pubkey.try_into().expect("split at PUBKEY_LEN"); - Ok((pubkey, secret.to_vec())) + 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)] @@ -204,35 +184,27 @@ mod tests { #[test] fn keys_are_deterministic_per_seed_and_distinct_per_role() { - let (a_pub, a_sec) = generate_key(7, 3, Role::Attestation, 2).unwrap(); - let (b_pub, b_sec) = generate_key(7, 3, Role::Attestation, 2).unwrap(); - assert_eq!(a_pub, b_pub); - assert_eq!(a_sec, b_sec); - let (p_pub, _) = generate_key(7, 3, Role::Proposal, 2).unwrap(); - assert_ne!(a_pub, p_pub); - let (s_pub, _) = generate_key(8, 3, Role::Attestation, 2).unwrap(); - assert_ne!(a_pub, s_pub); + 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 = std::env::temp_dir().join(format!( - "ethlambda-bench-keys-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let first = KeySet::generate(11, 1, 2, Some(&dir)).unwrap(); - let second = KeySet::generate(11, 1, 2, Some(&dir)).unwrap(); - assert_eq!(first.genesis_pubkeys(), second.genesis_pubkeys()); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); - let mut key_manager = second.into_key_manager().unwrap(); - assert_eq!(key_manager.validator_ids(), vec![0]); - key_manager + 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"); - std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/bin/ethlambda/src/benchmark/mod.rs b/bin/ethlambda/src/benchmark/mod.rs index 7cbc25f4..95f42855 100644 --- a/bin/ethlambda/src/benchmark/mod.rs +++ b/bin/ethlambda/src/benchmark/mod.rs @@ -19,16 +19,9 @@ use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::time::Instant; -use ethlambda_blockchain::block_builder::{ProposerConfig, 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::{ - on_block, on_block_without_verification, produce_block_with_signatures, -}; +use ethlambda_blockchain::block_builder::ProposerConfig; +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 _; @@ -166,8 +159,8 @@ fn run_synthetic(options: SyntheticOptions) -> eyre::Result<()> { // 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, mut key_manager) = if common.mock_crypto { - (CryptoMode::Mock, KeyManager::new(HashMap::new())) + let crypto = if common.mock_crypto { + CryptoMode::Mock } else { let keys = keys::KeySet::generate( options.seed, @@ -175,26 +168,23 @@ fn run_synthetic(options: SyntheticOptions) -> eyre::Result<()> { total_slots + 1, options.key_cache.as_deref(), )?; - let crypto = CryptoMode::Real { - genesis_pubkeys: keys.genesis_pubkeys(), - attestation_pubkeys: keys.attestation_pubkeys()?, - }; - (crypto, keys.into_key_manager()?) + CryptoMode::Real { + genesis_pubkeys: keys.genesis_pubkeys, + key_manager: keys.key_manager, + } }; - let corpus = + 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, - &mut key_manager, slot, options.num_validators, proposer_config, - common.mock_crypto, )?; let measured = slot > options.warmup_slots; log_progress(slot, total_slots, measured, &sample); @@ -241,20 +231,18 @@ fn log_progress(slot: u64, total_slots: u64, measured: bool, sample: &Sample) { ); } -/// Seed the pool, build (and in real mode seal) 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, - key_manager: &mut KeyManager, slot: u64, num_validators: u64, proposer_config: ProposerConfig, - mock_crypto: bool, ) -> eyre::Result { // Seed the pending pool with the previous slot's attestations, exactly // where gossip aggregates would sit before the proposal tick promotes them @@ -262,7 +250,7 @@ fn build_one_slot( // 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, key_manager, slot - 1) + .seed_pool(store, slot - 1) .wrap_err_with(|| format!("seeding the pool failed for slot {slot}"))?; // Round-robin proposer, matching `is_proposer`. @@ -274,20 +262,11 @@ fn build_one_slot( produce_block_with_signatures(store, slot, proposer, proposer_config) .wrap_err_with(|| format!("block build failed at slot {slot}"))?; let aggregates_count = aggregates.len(); - // The seal needs real signatures, so mock mode stops at the built block and - // imports it with an empty proof, the way the fork-choice spec tests do. - let signed_block = if mock_crypto { - SignedBlock { - message: block, - proof: MultiMessageAggregate::default(), - } - } else { - let head_state = store.head_state(); - seal_block(&head_state, key_manager, block, &aggregates) - .wrap_err_with(|| format!("sealing the block failed at slot {slot}"))? - }; + 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(expected_phases(mock_crypto))?; + let phases = phases.finish(corpus.phases())?; let block_root = signed_block.message.hash_tree_root(); let attestations_packed = signed_block.message.body.attestations.len(); @@ -295,16 +274,11 @@ fn build_one_slot( // 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. Real mode imports through `on_block`, so the merged proof is - // verified and a bad seal fails the run instead of producing a report - // about invalid blocks. + // index. let import_start = Instant::now(); - if mock_crypto { - on_block_without_verification(store, signed_block) - } else { - on_block(store, signed_block) - } - .wrap_err_with(|| format!("importing the built block failed at slot {slot}"))?; + 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 @@ -328,20 +302,6 @@ fn build_one_slot( }) } -/// The phases one slot observes: the build phases always, plus the seal phases -/// when the seal runs. -fn expected_phases(mock_crypto: bool) -> impl Iterator { - let seal = if mock_crypto { - &[][..] - } else { - BLOCK_PROPOSAL_SEAL_PHASES - }; - BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES - .iter() - .chain(seal) - .copied() -} - const PHASE_HISTOGRAM: &str = "lean_block_proposal_attestation_build_phase_seconds"; /// Exact per-phase durations for one block build and seal, taken from the diff --git a/bin/ethlambda/src/benchmark/report.rs b/bin/ethlambda/src/benchmark/report.rs index 112b96da..117f5884 100644 --- a/bin/ethlambda/src/benchmark/report.rs +++ b/bin/ethlambda/src/benchmark/report.rs @@ -130,30 +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 aggregate = stats( - &samples - .iter() - .map(|sample| sample.aggregate_seconds) - .collect::>(), - ); - let import = stats( - &samples - .iter() - .map(|sample| sample.import_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!( diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 94dd8a98..f6255964 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -17,7 +17,7 @@ use std::{ use ethlambda_crypto::{ AggregationError, aggregate_proofs, aggregate_signatures, merge_type_1s_into_type_2, - signature::{ValidatorPublicKey, ValidatorSignature}, + signature::{SignatureParseError, ValidatorPublicKey, ValidatorSignature}, }; use ethlambda_state_transition::{ attestation_data_matches_chain, justified_slots_ops, process_block, process_slots, @@ -27,12 +27,12 @@ use ethlambda_types::{ ShortRoot, attestation::{AggregatedAttestation, AggregationBits, AttestationData}, block::{ - AggregatedAttestations, Block, BlockBody, MultiMessageAggregate, SignedBlock, - SingleMessageAggregate, + AggregatedAttestations, Block, BlockBody, MultiMessageAggregate, + MultiMessageAggregateError, SignedBlock, SingleMessageAggregate, }, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, - state::{JustifiedSlots, State}, + state::{JustifiedSlots, State, Validator}, }; use tracing::{info, trace}; @@ -676,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>>()?; @@ -898,6 +885,26 @@ 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 { @@ -906,19 +913,17 @@ pub enum SealError { #[error("proposer index {0} out of range")] ProposerOutOfRange(u64), #[error("failed to decode proposer proposal pubkey: {0}")] - ProposerPubkey(String), + ProposerPubkey(SignatureParseError), #[error("failed to decode proposer signature bytes: {0}")] - ProposerSignature(String), - #[error("participant {0} out of range while resolving pubkeys")] - ParticipantOutOfRange(u64), - #[error("failed to decode attestation pubkey of validator {0}: {1}")] - ParticipantPubkey(u64, String), + 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(String), + Decode(#[from] MultiMessageAggregateError), } /// Seal a built block into a `SignedBlock`: sign the block root with the @@ -927,10 +932,11 @@ pub enum SealError { /// 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`. Per-component -/// participants are rederived at verify time from those attestations' -/// `aggregation_bits` plus `block.proposer_index`, so nothing else needs -/// persisting. +/// `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`]. @@ -938,7 +944,7 @@ pub fn seal_block( head_state: &State, key_manager: &mut KeyManager, block: Block, - single_message_aggregates: &[SingleMessageAggregate], + single_message_aggregates: Vec, ) -> Result { let slot: u32 = block.slot.try_into().expect("slot exceeds u32"); let proposer_index = block.proposer_index; @@ -957,9 +963,9 @@ pub fn seal_block( // 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(|err| SealError::ProposerPubkey(err.0))?; + .map_err(SealError::ProposerPubkey)?; let proposer_validator_signature = ValidatorSignature::from_bytes(&proposer_signature) - .map_err(|err| SealError::ProposerSignature(err.0))?; + .map_err(SealError::ProposerSignature)?; let wrap_start = Instant::now(); let proposer_proof_bytes = aggregate_signatures( @@ -973,24 +979,14 @@ pub fn seal_block( let mut merge_inputs = Vec::with_capacity(single_message_aggregates.len() + 1); for sma in single_message_aggregates { - let pubkeys = sma - .participant_indices() - .map(|vid| { - let validator = validators - .get(vid as usize) - .ok_or(SealError::ParticipantOutOfRange(vid))?; - ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) - .map_err(|err| SealError::ParticipantPubkey(vid, err.0)) - }) - .collect::, _>>()?; - merge_inputs.push((pubkeys, sma.proof.clone())); + 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()) - .map_err(|err| SealError::Decode(err.to_string()))?; + let proof = MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice())?; metrics::observe_block_proposal_phase("merge_type2", merge_start.elapsed()); Ok(SignedBlock { diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 5db7dc77..3e1d97f7 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -783,14 +783,13 @@ impl BlockChainServer { // aggregate, and merge it with every attestation aggregate into the // block's multi-message aggregate. let head_state = self.store.head_state(); - let seal_result = block_builder::seal_block( + let Ok(signed_block) = block_builder::seal_block( &head_state, &mut self.key_manager, block, - &single_message_aggregates, + single_message_aggregates, ) - .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to seal block")); - let Ok(signed_block) = seal_result else { + .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to seal block")) else { metrics::inc_block_building_failures(); return; }; diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 6edb2cd4..fcacddc8 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -499,7 +499,7 @@ 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(|| { 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