From 8304b37522afc517a66ca7ed9326cab06b38809c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:16:50 -0300 Subject: [PATCH 01/11] refactor(blockchain): group on_tick conditionals by interval on_tick ran its per-interval blocks out of order (4-snapshot, tick, 2, 0, 1). Regroup them into ascending interval order behind `==== interval N ====` markers so the slot timeline reads top to bottom and matches the duty schedule. Pure reorder, behavior unchanged. The interval-4 new_payloads snapshot is the one block that stays ahead of store::on_tick: the interval-4 tick promotes new_payloads out, so it cannot move into a post-tick group. A comment now pins that constraint. --- crates/blockchain/src/lib.rs | 39 ++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 168282ba..180143f4 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -245,10 +245,17 @@ impl BlockChainServer { info!(%slot, %validator_id, "Skipping block proposal while syncing"); } + // ==== interval 4 (pre-tick) ==== + // Snapshot the pre-merge `new_payloads` set at the end-of-slot promote // (interval 4), so the post-block report for this round sees its // "timely" cohort just before it is promoted out of `new_payloads`. // + // This MUST stay ahead of `store::on_tick` below: the interval-4 tick + // promotes `new_payloads` out, so snapshotting afterwards would capture + // an already-drained set. It is the one interval action that cannot live + // in its grouped block downstream. + // // Only interval 4 — not the proposer's interval-0 promote. By interval 0 // the round's votes have already been promoted at the previous slot's // interval 4; `new_payloads` then holds only stragglers, and snapshotting @@ -269,23 +276,15 @@ impl BlockChainServer { proposer_validator_id.is_some(), ); - if interval == 2 { - if is_aggregator { - coverage::emit_agg_start_new_coverage( - &self.store, - self.attestation_committee_count, - ); - self.start_aggregation_session(slot, ctx).await; - } else { - metrics::inc_aggregator_skipped_not_aggregator(); - } - } + // ==== interval 0 ==== // Now build and publish the block (after attestations have been accepted) if let Some(validator_id) = proposer_validator_id { self.propose_block(slot, validator_id); } + // ==== interval 1 ==== + // Produce attestations at interval 1 (all validators including proposer). // Reuse the same snapshot so self-delivery decisions match the rest // of the tick. @@ -309,6 +308,24 @@ impl BlockChainServer { } } + // ==== interval 2 ==== + + if interval == 2 { + if is_aggregator { + coverage::emit_agg_start_new_coverage( + &self.store, + self.attestation_committee_count, + ); + self.start_aggregation_session(slot, ctx).await; + } else { + metrics::inc_aggregator_skipped_not_aggregator(); + } + } + + // ==== interval 3 ==== + + // Interval 3 (safe-target update) is handled inside `store::on_tick`. + // Update safe target slot metric (updated by store.on_tick at interval 3) metrics::update_safe_target_slot(self.store.safe_target_slot()); // Update head slot metric (head may change when attestations are promoted at intervals 0/4) From 10e209fb0e13a5b436fea3e6bac06c492642d3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:23:16 -0300 Subject: [PATCH 02/11] docs: remove unnecessary comment --- crates/blockchain/src/lib.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 180143f4..8dbfdd9a 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -251,11 +251,6 @@ impl BlockChainServer { // (interval 4), so the post-block report for this round sees its // "timely" cohort just before it is promoted out of `new_payloads`. // - // This MUST stay ahead of `store::on_tick` below: the interval-4 tick - // promotes `new_payloads` out, so snapshotting afterwards would capture - // an already-drained set. It is the one interval action that cannot live - // in its grouped block downstream. - // // Only interval 4 — not the proposer's interval-0 promote. By interval 0 // the round's votes have already been promoted at the previous slot's // interval 4; `new_payloads` then holds only stragglers, and snapshotting From 84597269441abc11c9b71be08c1f4cde27632630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:26:28 -0300 Subject: [PATCH 03/11] docs: add closing interval-4 marker in on_tick --- crates/blockchain/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 8dbfdd9a..cbf848d8 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -321,6 +321,10 @@ impl BlockChainServer { // Interval 3 (safe-target update) is handled inside `store::on_tick`. + // ==== interval 4 ==== + + // Handled by the pre-tick snapshot above. + // Update safe target slot metric (updated by store.on_tick at interval 3) metrics::update_safe_target_slot(self.store.safe_target_slot()); // Update head slot metric (head may change when attestations are promoted at intervals 0/4) From d003f85a94b053072077798759a6dcfa65a45dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:32:13 -0300 Subject: [PATCH 04/11] refactor(blockchain): gate proposal at the call site, ungate the on_tick flag Compute scheduled_proposer just before store::on_tick and pass the raw is_proposer (= scheduled_proposer.is_some()) to it; gate the actual proposal on duties_allowed() at the call site instead. While syncing and scheduled to propose, on_tick now accepts attestations early at interval 0 (it did not before); the proposal itself is still skipped. --- crates/blockchain/src/lib.rs | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index cbf848d8..e2a32b1d 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -230,21 +230,6 @@ impl BlockChainServer { let is_aggregator = self.aggregator.is_enabled(); metrics::set_is_aggregator(is_aggregator); - // At interval 0, check if we will propose (but don't build the block yet). - // Tick forkchoice first to accept attestations, then build the block - // using the freshly-accepted attestations. - let scheduled_proposer = (interval == 0 && slot > 0) - .then(|| self.get_our_proposer(slot)) - .flatten(); - let proposer_validator_id = - scheduled_proposer.filter(|_| self.sync_status.duties_allowed()); - - if let Some(validator_id) = scheduled_proposer - && proposer_validator_id.is_none() - { - info!(%slot, %validator_id, "Skipping block proposal while syncing"); - } - // ==== interval 4 (pre-tick) ==== // Snapshot the pre-merge `new_payloads` set at the end-of-slot promote @@ -264,18 +249,23 @@ impl BlockChainServer { self.pre_merge_coverage = Some(snapshot); } + let scheduled_proposer = (interval == 0 && slot > 0) + .then(|| self.get_our_proposer(slot)) + .flatten(); + let is_proposer = scheduled_proposer.is_some(); + // Tick the store first - this accepts attestations at interval 0 if we have a proposal - store::on_tick( - &mut self.store, - timestamp_ms, - proposer_validator_id.is_some(), - ); + store::on_tick(&mut self.store, timestamp_ms, is_proposer); // ==== interval 0 ==== // Now build and publish the block (after attestations have been accepted) - if let Some(validator_id) = proposer_validator_id { - self.propose_block(slot, validator_id); + if let Some(validator_id) = scheduled_proposer { + if self.sync_status.duties_allowed() { + self.propose_block(slot, validator_id); + } else { + info!(%slot, %validator_id, "Skipping block proposal while syncing"); + } } // ==== interval 1 ==== From 2c6eb1c9542aeba6ef30a661b23ec31e56fd2e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:39:11 -0300 Subject: [PATCH 05/11] feat(blockchain): pre-build proposer block one interval early At interval 4, if one of our validators proposes the next slot, build and sign its block synchronously on the actor so the heavy leanVM aggregation (~1-2s) is done before interval 0. The proposer then only has to publish. Shares one block-build core (produce_block_on_head) across the prebuild and normal proposal paths; a publish-time usability gate falls back to a fresh build if the prebuilt block is stale. --- crates/blockchain/src/block_builder.rs | 95 +++++++++++- crates/blockchain/src/lib.rs | 198 ++++++++++++++++++++----- crates/blockchain/src/store.rs | 35 +++-- 3 files changed, 280 insertions(+), 48 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 722d98d7..7fc1d2c1 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -23,7 +23,7 @@ use ethlambda_state_transition::{ use ethlambda_types::{ ShortRoot, attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{AggregatedAttestations, Block, BlockBody, TypeOneMultiSignature}, + block::{AggregatedAttestations, Block, BlockBody, SignedBlock, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, state::{JustifiedSlots, State}, @@ -43,6 +43,40 @@ pub struct PostBlockCheckpoints { pub finalized: Checkpoint, } +/// A block built ahead of its proposal slot (at the previous slot's interval 4) +/// and signed, awaiting publication at interval 0. +pub(crate) struct PreparedBlock { + /// Proposal slot this block targets. + pub(crate) slot: u64, + /// Validator that will propose it. + pub(crate) validator_id: u64, + /// Head the block was built on. Must still be the canonical head at + /// publish time, or a late block / reorg has invalidated it. + pub(crate) parent_root: H256, + /// Justified slot the build closed over. Per leanSpec #595 the published + /// block must not lag the store's justified checkpoint; if the store's + /// justified slot advanced past this between build and publish, fall back. + pub(crate) built_justified_slot: u64, + /// Fully assembled block + Type-2 proof, ready to process and publish. + pub(crate) signed_block: SignedBlock, +} + +/// Decide whether a prepared block is still safe to publish at interval 0. +/// +/// Pure so it can be unit-tested without an actor or store. +pub(crate) fn prebuilt_block_is_usable( + prepared: &PreparedBlock, + proposal_slot: u64, + proposer_id: u64, + live_head: H256, + store_justified_slot: u64, +) -> bool { + prepared.slot == proposal_slot + && prepared.validator_id == proposer_id + && prepared.parent_root == live_head + && prepared.built_justified_slot >= store_justified_slot +} + /// Build a valid block on top of this state. /// /// Selects attestations via `select_attestations`, compacts duplicate @@ -1336,3 +1370,62 @@ mod tests { assert_eq!(covered, HashSet::from([0, 1, 2, 3])); } } + +#[cfg(test)] +mod prebuild_tests { + use super::*; + use ethlambda_types::block::{BlockBody, MultiMessageAggregate}; + + fn root(b: u8) -> H256 { + H256::from_slice(&[b; 32]) + } + + fn dummy_signed_block() -> SignedBlock { + SignedBlock { + message: Block { + slot: 0, + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: BlockBody::default(), + }, + proof: MultiMessageAggregate::default(), + } + } + + fn prepared(slot: u64, vid: u64, parent: H256, just: u64) -> PreparedBlock { + PreparedBlock { + slot, + validator_id: vid, + parent_root: parent, + built_justified_slot: just, + signed_block: dummy_signed_block(), + } + } + + #[test] + fn usable_when_head_and_justified_match() { + let p = prepared(10, 3, root(0xAB), 7); + assert!(prebuilt_block_is_usable(&p, 10, 3, root(0xAB), 7)); + } + + #[test] + fn unusable_when_head_moved() { + let p = prepared(10, 3, root(0xAB), 7); + assert!(!prebuilt_block_is_usable(&p, 10, 3, root(0xCD), 7)); + } + + #[test] + fn unusable_when_justified_advanced_past_build() { + let p = prepared(10, 3, root(0xAB), 7); + // store justified is now 8 > 7 → would regress justification. + assert!(!prebuilt_block_is_usable(&p, 10, 3, root(0xAB), 8)); + } + + #[test] + fn unusable_for_wrong_slot_or_proposer() { + let p = prepared(10, 3, root(0xAB), 7); + assert!(!prebuilt_block_is_usable(&p, 11, 3, root(0xAB), 7)); + assert!(!prebuilt_block_is_usable(&p, 10, 4, root(0xAB), 7)); + } +} diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index e2a32b1d..4be38433 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -8,7 +8,7 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ByteList512KiB, MultiMessageAggregate, SignedBlock}, + block::{Block, ByteList512KiB, MultiMessageAggregate, SignedBlock, TypeOneMultiSignature}, primitives::{H256, HashTreeRoot as _}, signature::{ValidatorPublicKey, ValidatorSignature}, }; @@ -17,6 +17,7 @@ use crate::aggregation::{ AGGREGATION_DEADLINE, AggregateProduced, AggregationDeadline, AggregationDone, AggregationSession, PRIOR_WORKER_JOIN_TIMEOUT, run_aggregation_worker, }; +use crate::block_builder::{PreparedBlock, prebuilt_block_is_usable}; use crate::key_manager::ValidatorKeyPair; use crate::sync_status::SyncStatusTracker; use spawned_concurrency::actor; @@ -103,6 +104,7 @@ impl BlockChain { aggregator, pending_block_parents: HashMap::new(), current_aggregation: None, + prepared_block: None, last_tick_instant: None, attestation_committee_count, pre_merge_coverage: None, @@ -158,6 +160,11 @@ pub struct BlockChainServer { /// the next interval 2 takes over. current_aggregation: Option, + /// Block built synchronously at the previous slot's interval 4, awaiting + /// publication at this proposal slot's interval 0. Cleared on use, on + /// staleness, or when superseded by the next interval-4 build. + prepared_block: Option, + /// Last tick instant for measuring interval duration. last_tick_instant: Option, @@ -313,7 +320,20 @@ impl BlockChainServer { // ==== interval 4 ==== - // Handled by the pre-tick snapshot above. + // The pre-merge `new_payloads` snapshot is taken pre-tick above. If one + // of our validators proposes the NEXT slot, build its block now + // (synchronously, blocking the actor) so the heavy leanVM work is done + // before interval 0 and the proposer only has to publish. + if interval == 4 { + let next_slot = slot + 1; + let next_proposer = self + .get_our_proposer(next_slot) + .filter(|_| self.sync_status.duties_allowed()); + + if let Some(validator_id) = next_proposer { + self.prebuild_block(next_slot, validator_id); + } + } // Update safe target slot metric (updated by store.on_tick at interval 3) metrics::update_safe_target_slot(self.store.safe_target_slot()); @@ -380,6 +400,74 @@ impl BlockChainServer { }); } + /// Build the next slot's block synchronously and stash it for publication + /// at interval 0. + /// + /// Runs on the actor thread, blocking it for the duration of the build + /// (the expensive part is the leanVM Type-1 → Type-2 merge). That is + /// acceptable here: between interval 4 and the next slot the actor has no + /// other consensus-critical duty, and a prepared block lets the proposer + /// publish at interval 0 without paying the build cost then. + fn prebuild_block(&mut self, slot: u64, validator_id: u64) { + // Build against the current canonical head, READ-ONLY. We must not use + // `get_proposal_head` here: it ticks the store to `slot` time one interval + // early, which would skew finalization and diverge the captured head from + // the interval-0 state (making every prebuilt block stale). The interval-4 + // promote has already run in `store::on_tick` this tick, so `store.head()` + // reflects the latest accepted attestations. + let parent_root = self.store.head(); + + let Some((signed_block, built_justified_slot)) = + self.build_signed_block(slot, validator_id, parent_root) + else { + return; + }; + + self.prepared_block = Some(PreparedBlock { + slot, + validator_id, + parent_root, + built_justified_slot, + signed_block, + }); + info!(%slot, %validator_id, "Pre-built block ready"); + } + + /// Build the block on `head_root` and assemble it into a `SignedBlock`. + /// + /// Shared by the interval-0 proposal path and the interval-4 pre-build; the + /// only difference between callers is how `head_root` is resolved (ticking + /// `get_proposal_head` vs read-only `store.head()`). Returns the signed block + /// and the justified slot it closed over, or `None` on any build/sign + /// failure (already logged and counted). + fn build_signed_block( + &mut self, + slot: u64, + validator_id: u64, + head_root: H256, + ) -> Option<(SignedBlock, u64)> { + let _timing = metrics::time_block_building(); + let (block, type_one_proofs, post_checkpoints) = + match store::produce_block_on_head(&mut self.store, slot, validator_id, head_root) { + Ok(built) => built, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to build block"); + metrics::inc_block_building_failures(); + return None; + } + }; + + coverage::emit_proposal_coverage( + &self.store, + self.attestation_committee_count, + block.body.attestations.iter(), + ); + + let signed_block = + self.assemble_signed_block(slot, validator_id, block, type_one_proofs)?; + Some((signed_block, post_checkpoints.justified.slot)) + } + /// Returns the validator ID if any of our validators is the proposer for this slot. fn get_our_proposer(&self, slot: u64) -> Option { let head_state = self.store.head_state(); @@ -442,24 +530,51 @@ impl BlockChainServer { fn propose_block(&mut self, slot: u64, validator_id: u64) { info!(%slot, %validator_id, "We are the proposer for this slot"); - let _timing = metrics::time_block_building(); - - // Build the block with attestation signatures - let Ok((block, type_one_proofs, _post_checkpoints)) = - store::produce_block_with_signatures(&mut self.store, slot, validator_id) - .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block")) - else { - metrics::inc_block_building_failures(); - return; - }; + // Resolve the canonical head once. This ticks the store to `slot` and + // accepts pending attestations, so both the pre-built-block revalidation + // and a fresh build below see the same interval-0 state. + let head_root = store::get_proposal_head(&mut self.store, slot); + + // Fast path: publish a block pre-built at the previous slot's interval 4, + // if it is still valid against the live head and justified checkpoint. + if let Some(prepared) = self.prepared_block.take() { + let store_justified_slot = self.store.latest_justified().slot; + if prebuilt_block_is_usable( + &prepared, + slot, + validator_id, + head_root, + store_justified_slot, + ) && self.process_and_publish_block( + slot, + validator_id, + prepared.signed_block, + "Published pre-built block", + ) { + return; + } + // Stale, or import failed: fall through to a fresh synchronous build. + info!(%slot, %validator_id, "Pre-built block unusable; rebuilding"); + } - coverage::emit_proposal_coverage( - &self.store, - self.attestation_committee_count, - block.body.attestations.iter(), - ); + if let Some((signed_block, _)) = self.build_signed_block(slot, validator_id, head_root) { + self.process_and_publish_block(slot, validator_id, signed_block, "Published block"); + } + } - // Sign the block root with the proposal key + /// Sign the block root and merge every Type-1 proof (attestations plus the + /// proposer's own signature) into the block's single Type-2 proof. + /// + /// Shared by the synchronous proposal path and `prebuild_block`. Returns + /// `None` on any signing/aggregation failure (already logged and counted). + fn assemble_signed_block( + &mut self, + slot: u64, + validator_id: u64, + block: Block, + type_one_proofs: Vec, + ) -> Option { + // Sign the block root with the proposal key. let block_root = block.hash_tree_root(); let Ok(proposer_signature) = self .key_manager @@ -467,18 +582,17 @@ impl BlockChainServer { .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to sign block root")) else { metrics::inc_block_building_failures(); - return; + return None; }; - // Assemble SignedBlock: wrap the proposer's raw XMSS signature into a - // singleton Type-1 SNARK, then merge it with every attestation Type-1 - // into the block's single Type-2 proof. + // Wrap the proposer's raw XMSS signature into a singleton Type-1 SNARK, + // then merge it with every attestation Type-1 into the single Type-2. 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; + return None; }; // Decode the proposer's proposal pubkey once and reuse it both for the @@ -487,7 +601,7 @@ impl BlockChainServer { |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), ) else { metrics::inc_block_building_failures(); - return; + return None; }; let Ok(proposer_validator_signature) = @@ -496,7 +610,7 @@ impl BlockChainServer { }) else { metrics::inc_block_building_failures(); - return; + return None; }; let Ok(proposer_proof_bytes) = ethlambda_crypto::aggregate_signatures( vec![proposer_pubkey.clone()], @@ -508,7 +622,7 @@ impl BlockChainServer { |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1"), ) else { metrics::inc_block_building_failures(); - return; + return None; }; let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = @@ -538,7 +652,7 @@ impl BlockChainServer { } if resolve_failed { metrics::inc_block_building_failures(); - return; + return None; } merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); @@ -551,7 +665,7 @@ impl BlockChainServer { Err(err) => { error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); metrics::inc_block_building_failures(); - return; + return None; } }; let proof = match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { @@ -559,33 +673,41 @@ impl BlockChainServer { Err(err) => { error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); metrics::inc_block_building_failures(); - return; + return None; } }; - // `type_one_proofs` is no longer needed past this point. - drop(type_one_proofs); - let signed_block = SignedBlock { + Some(SignedBlock { message: block, proof, - }; + }) + } - // Process the block locally before publishing + /// Import a freshly built block locally, then publish it to gossip. Returns + /// `true` on successful import; on failure logs, counts it, and returns + /// `false` so the caller can fall back to a fresh build. + fn process_and_publish_block( + &mut self, + slot: u64, + validator_id: u64, + signed_block: SignedBlock, + published_msg: &'static str, + ) -> bool { if let Err(err) = self.process_block(signed_block.clone()) { error!(%slot, %validator_id, %err, "Failed to process built block"); metrics::inc_block_building_failures(); - return; - }; + return false; + } metrics::inc_block_building_success(); - // Publish to gossip network if let Some(ref p2p) = self.p2p { let _ = p2p .publish_block(signed_block) .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to publish block")); } - info!(%slot, %validator_id, "Published block"); + info!(%slot, %validator_id, "{}", published_msg); + true } /// Run block import and refresh metrics. diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 8f7807d2..9448e096 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -752,9 +752,11 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { /// Get the head for block proposal at the given slot. /// -/// Ensures store is up-to-date and processes any pending attestations -/// before returning the canonical head. -fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { +/// NOT read-only: advances the store clock to `slot` and promotes pending +/// attestations before returning the canonical head. Use only at interval 0 +/// (the proposal tick); callers that must not move the clock should read +/// [`Store::head`] directly. +pub(crate) fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { // Calculate time corresponding to this slot let slot_time_ms = store.config().genesis_time * 1000 + slot * MILLISECONDS_PER_SLOT; @@ -767,24 +769,39 @@ fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { store.head() } -/// Produce a block and per-aggregated-attestation signature payloads for the target slot. +/// Produce a block and its signature payloads, resolving the head via +/// [`get_proposal_head`] (which advances the store clock to `slot`). /// -/// Returns the finalized block and attestation signature payloads aligned -/// with `block.body.attestations`. +/// Use at interval 0. To build against an already-known head without ticking +/// the clock (e.g. a pre-build one interval early), call [`produce_block_on_head`]. pub fn produce_block_with_signatures( store: &mut Store, slot: u64, validator_index: u64, ) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { - // Get parent block and state to build upon let head_root = get_proposal_head(store, slot); + produce_block_on_head(store, slot, validator_index, head_root) +} + +/// Produce a block and per-aggregated-attestation signature payloads on top of +/// `head_root`, without moving the store clock. +/// +/// Returns the block and attestation signature payloads aligned with +/// `block.body.attestations`. Shared by the interval-0 proposal path and the +/// interval-4 pre-build; the only difference between them is how `head_root` is +/// resolved (ticking vs read-only). +pub(crate) fn produce_block_on_head( + store: &mut Store, + slot: u64, + validator_index: u64, + head_root: H256, +) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { let head_state = store .get_state(&head_root) .ok_or(StoreError::MissingParentState { parent_root: head_root, slot, - })? - .clone(); + })?; // Validate proposer authorization for this slot let num_validators = head_state.validators.len() as u64; From 1e58c02526450e3feb9637e44553b34970a9899e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:05:22 -0300 Subject: [PATCH 06/11] fix(blockchain): publish proposer block at interval 4, aligned to slot start The proposer pre-built its block at the previous slot's interval 4 and stashed it for the interval-0 tick to publish. But the build takes longer than one 0.8s interval, so it overruns the slot boundary; handle_tick's overrun catch-up (#413) then re-ticks at the current interval and skips the missed interval-0. The stashed block was never published and block production halted (head frozen, every proposer blocked by its own prebuild). Consolidate the proposal into a single path that runs entirely at interval 4: build on the current head, align publication to the slot boundary (sleep out the remainder if the build finished early, publish immediately if it overran), revalidate the head and rebuild if it moved, then publish. This never depends on the interval-0 tick firing. Remove the now-dead dual-path machinery: the prepared_block stash, PreparedBlock/prebuilt_block_is_usable, the interval-0 propose_block, and the orphaned produce_block_with_signatures/get_proposal_head clock-ticking build path. --- crates/blockchain/src/block_builder.rs | 104 ++++-------------- crates/blockchain/src/lib.rs | 142 ++++++++++--------------- crates/blockchain/src/store.rs | 41 +------ 3 files changed, 81 insertions(+), 206 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 7fc1d2c1..052f1a55 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -23,7 +23,7 @@ use ethlambda_state_transition::{ use ethlambda_types::{ ShortRoot, attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{AggregatedAttestations, Block, BlockBody, SignedBlock, TypeOneMultiSignature}, + block::{AggregatedAttestations, Block, BlockBody, TypeOneMultiSignature}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, state::{JustifiedSlots, State}, @@ -43,38 +43,16 @@ pub struct PostBlockCheckpoints { pub finalized: Checkpoint, } -/// A block built ahead of its proposal slot (at the previous slot's interval 4) -/// and signed, awaiting publication at interval 0. -pub(crate) struct PreparedBlock { - /// Proposal slot this block targets. - pub(crate) slot: u64, - /// Validator that will propose it. - pub(crate) validator_id: u64, - /// Head the block was built on. Must still be the canonical head at - /// publish time, or a late block / reorg has invalidated it. - pub(crate) parent_root: H256, - /// Justified slot the build closed over. Per leanSpec #595 the published - /// block must not lag the store's justified checkpoint; if the store's - /// justified slot advanced past this between build and publish, fall back. - pub(crate) built_justified_slot: u64, - /// Fully assembled block + Type-2 proof, ready to process and publish. - pub(crate) signed_block: SignedBlock, -} - -/// Decide whether a prepared block is still safe to publish at interval 0. +/// Whether a pre-build that has just finished has already run into (or past) +/// the start of its target slot — i.e. into the interval-0 publish window. +/// +/// When true, the overrun-catch-up in `handle_tick` will have skipped that +/// slot's interval-0 proposal tick, so the freshly built block must be +/// published in place rather than stashed for a tick that will never fire. /// /// Pure so it can be unit-tested without an actor or store. -pub(crate) fn prebuilt_block_is_usable( - prepared: &PreparedBlock, - proposal_slot: u64, - proposer_id: u64, - live_head: H256, - store_justified_slot: u64, -) -> bool { - prepared.slot == proposal_slot - && prepared.validator_id == proposer_id - && prepared.parent_root == live_head - && prepared.built_justified_slot >= store_justified_slot +pub(crate) fn build_overran_publish_window(now_ms: u64, genesis_time_ms: u64, slot: u64) -> bool { + now_ms >= genesis_time_ms + slot * crate::MILLISECONDS_PER_SLOT } /// Build a valid block on top of this state. @@ -1374,58 +1352,20 @@ mod tests { #[cfg(test)] mod prebuild_tests { use super::*; - use ethlambda_types::block::{BlockBody, MultiMessageAggregate}; - - fn root(b: u8) -> H256 { - H256::from_slice(&[b; 32]) - } - - fn dummy_signed_block() -> SignedBlock { - SignedBlock { - message: Block { - slot: 0, - proposer_index: 0, - parent_root: H256::ZERO, - state_root: H256::ZERO, - body: BlockBody::default(), - }, - proof: MultiMessageAggregate::default(), - } - } - - fn prepared(slot: u64, vid: u64, parent: H256, just: u64) -> PreparedBlock { - PreparedBlock { - slot, - validator_id: vid, - parent_root: parent, - built_justified_slot: just, - signed_block: dummy_signed_block(), - } - } - - #[test] - fn usable_when_head_and_justified_match() { - let p = prepared(10, 3, root(0xAB), 7); - assert!(prebuilt_block_is_usable(&p, 10, 3, root(0xAB), 7)); - } - - #[test] - fn unusable_when_head_moved() { - let p = prepared(10, 3, root(0xAB), 7); - assert!(!prebuilt_block_is_usable(&p, 10, 3, root(0xCD), 7)); - } - - #[test] - fn unusable_when_justified_advanced_past_build() { - let p = prepared(10, 3, root(0xAB), 7); - // store justified is now 8 > 7 → would regress justification. - assert!(!prebuilt_block_is_usable(&p, 10, 3, root(0xAB), 8)); - } #[test] - fn unusable_for_wrong_slot_or_proposer() { - let p = prepared(10, 3, root(0xAB), 7); - assert!(!prebuilt_block_is_usable(&p, 11, 3, root(0xAB), 7)); - assert!(!prebuilt_block_is_usable(&p, 10, 4, root(0xAB), 7)); + fn overran_only_once_now_reaches_slot_start() { + let genesis = 1000; + let slot = 10; + let slot_start = genesis + slot * crate::MILLISECONDS_PER_SLOT; + // Build finished before the target slot opened: stash for interval 0. + assert!(!build_overran_publish_window(slot_start - 1, genesis, slot)); + // Build finished exactly at / past the slot start: publish in place. + assert!(build_overran_publish_window(slot_start, genesis, slot)); + assert!(build_overran_publish_window( + slot_start + 5_000, + genesis, + slot + )); } } diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 60e66a51..fb8b6747 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -17,7 +17,7 @@ use crate::aggregation::{ AGGREGATION_DEADLINE, AggregateProduced, AggregationDeadline, AggregationDone, AggregationSession, PRIOR_WORKER_JOIN_TIMEOUT, run_aggregation_worker, }; -use crate::block_builder::{PreparedBlock, prebuilt_block_is_usable}; +use crate::block_builder::build_overran_publish_window; use crate::key_manager::ValidatorKeyPair; use crate::sync_status::SyncStatusTracker; use spawned_concurrency::actor; @@ -105,7 +105,6 @@ impl BlockChain { aggregator, pending_block_parents: HashMap::new(), current_aggregation: None, - prepared_block: None, last_tick_instant: None, attestation_committee_count, pre_merge_coverage: None, @@ -161,11 +160,6 @@ pub struct BlockChainServer { /// the next interval 2 takes over. current_aggregation: Option, - /// Block built synchronously at the previous slot's interval 4, awaiting - /// publication at this proposal slot's interval 0. Cleared on use, on - /// staleness, or when superseded by the next interval-4 build. - prepared_block: Option, - /// Last tick instant for measuring interval duration. last_tick_instant: Option, @@ -259,25 +253,17 @@ impl BlockChainServer { self.pre_merge_coverage = Some(snapshot); } - let scheduled_proposer = (interval == 0 && slot > 0) + // Whether one of our validators proposes this slot. Drives the store's + // interval-0 attestation acceptance. The proposal itself is built and + // published one interval early — see the interval-4 block below. + let is_proposer = (interval == 0 && slot > 0) .then(|| self.get_our_proposer(slot)) - .flatten(); - let is_proposer = scheduled_proposer.is_some(); + .flatten() + .is_some(); // Tick the store first - this accepts attestations at interval 0 if we have a proposal store::on_tick(&mut self.store, timestamp_ms, is_proposer); - // ==== interval 0 ==== - - // Now build and publish the block (after attestations have been accepted) - if let Some(validator_id) = scheduled_proposer { - if self.sync_status.duties_allowed() { - self.propose_block(slot, validator_id); - } else { - info!(%slot, %validator_id, "Skipping block proposal while syncing"); - } - } - // ==== interval 1 ==== // Produce attestations at interval 1 (all validators including proposer). @@ -323,10 +309,12 @@ impl BlockChainServer { // ==== interval 4 ==== - // The pre-merge `new_payloads` snapshot is taken pre-tick above. If one - // of our validators proposes the NEXT slot, build its block now - // (synchronously, blocking the actor) so the heavy leanVM work is done - // before interval 0 and the proposer only has to publish. + // Build and publish the NEXT slot's block here, one interval early, so + // the heavy leanVM work happens during this otherwise-idle interval. + // `propose_block` blocks the actor for the build and aligns publication + // to the slot boundary. Doing the whole proposal here — rather than + // stashing it for the interval-0 tick — keeps it robust: `handle_tick` + // skips the interval-0 tick whenever this build overruns its interval. if interval == 4 { let next_slot = slot + 1; let next_proposer = self @@ -334,7 +322,7 @@ impl BlockChainServer { .filter(|_| self.sync_status.duties_allowed()); if let Some(validator_id) = next_proposer { - self.prebuild_block(next_slot, validator_id); + self.propose_block(next_slot, validator_id).await; } } @@ -403,21 +391,24 @@ impl BlockChainServer { }); } - /// Build the next slot's block synchronously and stash it for publication - /// at interval 0. + /// Build the target slot's block and publish it, one interval early. /// - /// Runs on the actor thread, blocking it for the duration of the build - /// (the expensive part is the leanVM Type-1 → Type-2 merge). That is - /// acceptable here: between interval 4 and the next slot the actor has no - /// other consensus-critical duty, and a prepared block lets the proposer - /// publish at interval 0 without paying the build cost then. - fn prebuild_block(&mut self, slot: u64, validator_id: u64) { + /// Runs at the previous slot's interval 4, blocking the actor for the build + /// (the expensive part is the leanVM Type-1 → Type-2 merge). The block is + /// built against the current canonical head; publication is aligned to the + /// slot boundary. If the build finishes before the slot opens we wait out + /// the remainder so the block is not published early; if it overran (the + /// common case under load) we publish at once. The whole proposal is + /// self-contained here, so it never depends on the interval-0 tick — which + /// `handle_tick` skips whenever this build overruns its interval. + async fn propose_block(&mut self, slot: u64, validator_id: u64) { + info!(%slot, %validator_id, "We are the proposer for this slot"); + // Build against the current canonical head, READ-ONLY. We must not use - // `get_proposal_head` here: it ticks the store to `slot` time one interval - // early, which would skew finalization and diverge the captured head from - // the interval-0 state (making every prebuilt block stale). The interval-4 - // promote has already run in `store::on_tick` this tick, so `store.head()` - // reflects the latest accepted attestations. + // `get_proposal_head` here: it ticks the store to `slot` time, which at + // interval 4 is one interval early and would skew finalization. The + // interval-4 promote has already run in `store::on_tick` this tick, so + // `store.head()` reflects the latest accepted attestations. let parent_root = self.store.head(); let Some((signed_block, built_justified_slot)) = @@ -426,23 +417,35 @@ impl BlockChainServer { return; }; - self.prepared_block = Some(PreparedBlock { - slot, - validator_id, - parent_root, - built_justified_slot, - signed_block, - }); - info!(%slot, %validator_id, "Pre-built block ready"); + // Align publication to the slot boundary. If the build finished before + // the slot opened, wait out the remainder so the block is not published + // early; if it overran, publish immediately. + let genesis_time_ms = self.store.config().genesis_time * 1000; + if !build_overran_publish_window(unix_now_ms(), genesis_time_ms, slot) { + let slot_start_ms = genesis_time_ms + slot * MILLISECONDS_PER_SLOT; + let wait_ms = slot_start_ms.saturating_sub(unix_now_ms()); + tokio::time::sleep(Duration::from_millis(wait_ms)).await; + } + + // The build (and any wait) may have let a competing block become head. + // Publish on the head we built on if it still holds and our justified + // checkpoint has not regressed past it; otherwise rebuild on the live + // head (the slot has already opened, so publish the rebuild at once). + let live_head = self.store.head(); + let store_justified_slot = self.store.latest_justified().slot; + if live_head == parent_root && built_justified_slot >= store_justified_slot { + self.process_and_publish_block(slot, validator_id, signed_block, "Published block"); + } else if let Some((rebuilt, _)) = self.build_signed_block(slot, validator_id, live_head) { + info!(%slot, %validator_id, "Head moved during pre-build; rebuilt on new head"); + self.process_and_publish_block(slot, validator_id, rebuilt, "Published block"); + } } /// Build the block on `head_root` and assemble it into a `SignedBlock`. /// - /// Shared by the interval-0 proposal path and the interval-4 pre-build; the - /// only difference between callers is how `head_root` is resolved (ticking - /// `get_proposal_head` vs read-only `store.head()`). Returns the signed block - /// and the justified slot it closed over, or `None` on any build/sign - /// failure (already logged and counted). + /// Returns the signed block and the justified slot it closed over, or `None` + /// on any build/sign failure (already logged and counted). Used by + /// `propose_block`, both for the initial build and the rebuild-on-moved-head. fn build_signed_block( &mut self, slot: u64, @@ -530,41 +533,6 @@ impl BlockChainServer { } /// Build and publish a block for the given slot and validator. - fn propose_block(&mut self, slot: u64, validator_id: u64) { - info!(%slot, %validator_id, "We are the proposer for this slot"); - - // Resolve the canonical head once. This ticks the store to `slot` and - // accepts pending attestations, so both the pre-built-block revalidation - // and a fresh build below see the same interval-0 state. - let head_root = store::get_proposal_head(&mut self.store, slot); - - // Fast path: publish a block pre-built at the previous slot's interval 4, - // if it is still valid against the live head and justified checkpoint. - if let Some(prepared) = self.prepared_block.take() { - let store_justified_slot = self.store.latest_justified().slot; - if prebuilt_block_is_usable( - &prepared, - slot, - validator_id, - head_root, - store_justified_slot, - ) && self.process_and_publish_block( - slot, - validator_id, - prepared.signed_block, - "Published pre-built block", - ) { - return; - } - // Stale, or import failed: fall through to a fresh synchronous build. - info!(%slot, %validator_id, "Pre-built block unusable; rebuilding"); - } - - if let Some((signed_block, _)) = self.build_signed_block(slot, validator_id, head_root) { - self.process_and_publish_block(slot, validator_id, signed_block, "Published block"); - } - } - /// Sign the block root and merge every Type-1 proof (attestations plus the /// proposer's own signature) into the block's single Type-2 proof. /// diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 9448e096..69953138 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -18,7 +18,7 @@ use tracing::{info, trace, warn}; use crate::{ GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA, - MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, + MILLISECONDS_PER_INTERVAL, block_builder::{PostBlockCheckpoints, build_block}, metrics, }; @@ -750,46 +750,13 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { } } -/// Get the head for block proposal at the given slot. -/// -/// NOT read-only: advances the store clock to `slot` and promotes pending -/// attestations before returning the canonical head. Use only at interval 0 -/// (the proposal tick); callers that must not move the clock should read -/// [`Store::head`] directly. -pub(crate) fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { - // Calculate time corresponding to this slot - let slot_time_ms = store.config().genesis_time * 1000 + slot * MILLISECONDS_PER_SLOT; - - // Advance time to current slot (ticking intervals) - on_tick(store, slot_time_ms, true); - - // Process any pending attestations before proposal - accept_new_attestations(store, false); - - store.head() -} - -/// Produce a block and its signature payloads, resolving the head via -/// [`get_proposal_head`] (which advances the store clock to `slot`). -/// -/// Use at interval 0. To build against an already-known head without ticking -/// the clock (e.g. a pre-build one interval early), call [`produce_block_on_head`]. -pub fn produce_block_with_signatures( - store: &mut Store, - slot: u64, - validator_index: u64, -) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { - let head_root = get_proposal_head(store, slot); - produce_block_on_head(store, slot, validator_index, head_root) -} - /// Produce a block and per-aggregated-attestation signature payloads on top of /// `head_root`, without moving the store clock. /// /// Returns the block and attestation signature payloads aligned with -/// `block.body.attestations`. Shared by the interval-0 proposal path and the -/// interval-4 pre-build; the only difference between them is how `head_root` is -/// resolved (ticking vs read-only). +/// `block.body.attestations`. The proposer resolves `head_root` from +/// [`Store::head`] at the previous slot's interval 4 (read-only); the build +/// must not tick the store, which would advance the clock an interval early. pub(crate) fn produce_block_on_head( store: &mut Store, slot: u64, From e48f43c1899dceda2acbf8fa3aa9f7327b5f83ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:38:01 -0300 Subject: [PATCH 07/11] docs(blockchain): clarify interval-4 proposal after review Review follow-up to the interval-4 proposal consolidation; no behavior change: - Restore the interval-0 section marker in on_tick with a note that the block is built and published at interval 4 of the previous slot. - Re-comment the head-revalidation guard in propose_block: under the single-threaded actor it always passes today (no message interleaves mid-build, even across the align-sleep), and is kept as a safety net for if the build is ever moved off the actor thread. - Fix stale references to the removed prebuild_block / synchronous proposal path / produce_block_with_signatures in doc comments, the CLAUDE.md tick-duties table, and the architecture infographic. --- CLAUDE.md | 4 ++-- crates/blockchain/src/lib.rs | 24 ++++++++++++++----- docs/infographics/ethlambda_architecture.html | 2 +- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b10c6dd..4aed588b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,11 +47,11 @@ crates/ ### Tick-Based Validator Duties (4-second slots, 5 intervals per slot) ``` -Interval 0: Block proposal → accept attestations if proposal exists +Interval 0: Accept attestations if we are this slot's proposer (block is built/published at interval 4 of the previous slot) Interval 1: Attestation production (all validators, including proposer) Interval 2: Aggregation (aggregators create proofs from gossip signatures) Interval 3: Safe target update (fork choice) -Interval 4: Accept accumulated attestations +Interval 4: Accept accumulated attestations; build + publish the NEXT slot's block (aligned to the slot boundary) ``` ### Attestation Pipeline diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index fb8b6747..bfa97b28 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -264,6 +264,13 @@ impl BlockChainServer { // Tick the store first - this accepts attestations at interval 0 if we have a proposal store::on_tick(&mut self.store, timestamp_ms, is_proposer); + // ==== interval 0 ==== + + // Block building/proposal is handled at interval 4 of the previous slot + // (see below): the proposer builds one interval early and publishes + // aligned to this slot's boundary. The only interval-0 work is the + // store tick above accepting attestations when we have a proposal. + // ==== interval 1 ==== // Produce attestations at interval 1 (all validators including proposer). @@ -427,10 +434,15 @@ impl BlockChainServer { tokio::time::sleep(Duration::from_millis(wait_ms)).await; } - // The build (and any wait) may have let a competing block become head. - // Publish on the head we built on if it still holds and our justified - // checkpoint has not regressed past it; otherwise rebuild on the live - // head (the slot has already opened, so publish the rebuild at once). + // Publish on the head we built on, rebuilding if it no longer holds. + // + // Today this guard always passes: the actor is single-threaded and + // processes no other message while `propose_block` runs (not even across + // the sleep above), so `store.head()` cannot change mid-build and our + // justified checkpoint cannot regress. The check is kept as a cheap + // safety net for the day the build is moved off the actor thread (where + // a competing block could land mid-build); then the rebuild path becomes + // live and the slot has already opened, so the rebuild publishes at once. let live_head = self.store.head(); let store_justified_slot = self.store.latest_justified().slot; if live_head == parent_root && built_justified_slot >= store_justified_slot { @@ -536,8 +548,8 @@ impl BlockChainServer { /// Sign the block root and merge every Type-1 proof (attestations plus the /// proposer's own signature) into the block's single Type-2 proof. /// - /// Shared by the synchronous proposal path and `prebuild_block`. Returns - /// `None` on any signing/aggregation failure (already logged and counted). + /// Called from `build_signed_block`. Returns `None` on any + /// signing/aggregation failure (already logged and counted). fn assemble_signed_block( &mut self, slot: u64, diff --git a/docs/infographics/ethlambda_architecture.html b/docs/infographics/ethlambda_architecture.html index 466483ae..c05b58f9 100644 --- a/docs/infographics/ethlambda_architecture.html +++ b/docs/infographics/ethlambda_architecture.html @@ -751,7 +751,7 @@

ethlambda

'Two-phase attestation pipeline: new -> known', 'on_tick(): advance slot, promote attestations at intervals 0/3', 'on_block(): state transition + fork choice + storage persist', - 'produce_block_with_signatures(): block building for proposers', + 'produce_block_on_head(): block building for proposers', 'justified_slots: relative indexing from finalized_slot', 'Fallback pruning for stalled finalization', ], From f34e81cfdcdbaebe55504e42a8df48845dc5e3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:55:28 -0300 Subject: [PATCH 08/11] fix(blockchain): drop the proposer's interval-0 attestation accept With block proposal moved to interval 4 of the previous slot, the interval-0 accept_new_attestations fired after the block had already been built and published: too late to be included in it, and it diverged the proposer's fork-choice view from the one its block closed over. Accepting should happen immediately before the build, which the unconditional interval-4 accept already does (it runs at the top of the same tick, just before propose_block). Comment out the interval-0 accept and its gate. has_proposal is now unused but kept in the signature (discarded via `let _`) so re-enabling needs no call-site change. --- crates/blockchain/src/store.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 69953138..f5598c70 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -255,6 +255,11 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() /// slot = store.time() / INTERVALS_PER_SLOT /// interval = store.time() % INTERVALS_PER_SLOT pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { + // `has_proposal` currently has no effect: it gated the interval-0 attestation + // accept that is now disabled (see the interval 0 arm below). Kept in the + // signature so re-enabling that path needs no call-site change. + let _ = has_proposal; + // Convert UNIX timestamp (ms) to interval count since genesis let genesis_time_ms = store.config().genesis_time * 1000; let time_delta_ms = timestamp_ms.saturating_sub(genesis_time_ms); @@ -274,10 +279,6 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { trace!(%slot, %interval, "processing tick"); - // has_proposal is only signaled for the final tick (matching Python spec behavior) - let is_final_tick = store.time() == time; - let should_signal_proposal = has_proposal && is_final_tick; - // NOTE: here we assume on_tick never skips intervals. // Interval 2 (committee-signature aggregation) is no longer handled here: // the blockchain actor orchestrates the aggregation worker directly so @@ -285,10 +286,25 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { // proofs. See `BlockChainServer::start_aggregation_session` in `lib.rs`. match interval { 0 => { - // Start of slot - process attestations if proposal exists - if should_signal_proposal { - accept_new_attestations(store, false); - } + // Interval-0 attestation acceptance is intentionally disabled. + // + // It used to promote pending attestations (new -> known) and + // refresh the head at the start of the slot, so the proposer's + // block — built at interval 0 — closed over the freshest votes. + // The proposer now builds and publishes its block at interval 4 + // of the PREVIOUS slot, so by the time we reach interval 0 the + // block is already out. Promoting here would accept attestations + // only AFTER the block was built: too late to be included, and + // it would diverge the proposer's fork-choice view from the one + // its block closed over. We want the accept immediately BEFORE + // the build, which is exactly what the unconditional interval-4 + // accept below does — it runs at the top of the same tick, just + // before `propose_block`. (`has_proposal` is thus no longer read.) + // + // let is_final_tick = store.time() == time; + // if has_proposal && is_final_tick { + // accept_new_attestations(store, false); + // } } 1 => { // Vote propagation — no action From 337f2d82cffc9e2342eeb6938e930b1c2e7be6db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:57:03 -0300 Subject: [PATCH 09/11] docs: interval 0 has no duty after dropping its attestation accept Follow-up to disabling the proposer's interval-0 accept_new_attestations: attestation promotion now happens only at interval 4, so the tick-duties table and the attestation-pipeline note are updated to match. --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4aed588b..2319b8c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ crates/ ### Tick-Based Validator Duties (4-second slots, 5 intervals per slot) ``` -Interval 0: Accept attestations if we are this slot's proposer (block is built/published at interval 4 of the previous slot) +Interval 0: No duty (the block is built/published at interval 4 of the previous slot; attestations are no longer accepted here) Interval 1: Attestation production (all validators, including proposer) Interval 2: Aggregation (aggregators create proofs from gossip signatures) Interval 3: Safe target update (fork choice) @@ -57,7 +57,7 @@ Interval 4: Accept accumulated attestations; build + publish the NEXT slot's blo ### Attestation Pipeline ``` Gossip → Signature verification → new_attestations (pending) - ↓ (intervals 0/4) + ↓ (interval 4) promote → known_attestations (fork choice active) ↓ Fork choice head update From 993e606f43ca96f15c93caf9417d38a255302b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:57:53 -0300 Subject: [PATCH 10/11] docs: clarify interval-0 publish; drop its attestation accept The block is published at interval 0 (the slot boundary). The build+publish code path is merged into the previous slot's interval 4 and aligned to publish at the boundary, but conceptually publication belongs to interval 0. Also reflect that the proposer's interval-0 accept_new_attestations was dropped, so promotion now happens only at interval 4. --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2319b8c7..10f535d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,11 +47,11 @@ crates/ ### Tick-Based Validator Duties (4-second slots, 5 intervals per slot) ``` -Interval 0: No duty (the block is built/published at interval 4 of the previous slot; attestations are no longer accepted here) +Interval 0: Block published (at the slot boundary). The build+publish code path is merged into the previous slot's interval 4 (see below) and aligned to publish here; no attestation acceptance happens at interval 0. Interval 1: Attestation production (all validators, including proposer) Interval 2: Aggregation (aggregators create proofs from gossip signatures) Interval 3: Safe target update (fork choice) -Interval 4: Accept accumulated attestations; build + publish the NEXT slot's block (aligned to the slot boundary) +Interval 4: Accept accumulated attestations; build the NEXT slot's block and publish it aligned to that slot's interval 0 (build and publish merged into this tick) ``` ### Attestation Pipeline From 59ce2e04ca865767ae394a0a66c42cdbb25f43a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:24:35 -0300 Subject: [PATCH 11/11] refactor(blockchain): advance store to interval 0 in the prebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the proposer's interval-0 deviation out of store::on_tick and into the actor. Commenting out store::on_tick's interval-0 attestation accept (f34e81c) changed a function the conformance tests drive directly; revert store::on_tick to its spec-faithful form. Instead, propose_block (which runs at the previous slot's interval 4) now advances the store to the target slot's interval 0 via store::on_tick(.., true) — accepting attestations exactly as the real interval-0 tick would — before building, so the block closes over the same interval-0 state a non-prebuilding proposer would see. Publication is still aligned to the slot boundary, and the real interval-0 tick is skipped by the idempotency guard since the store clock is already advanced. --- crates/blockchain/src/lib.rs | 41 ++++++++++++++++++++-------------- crates/blockchain/src/store.rs | 32 +++++++------------------- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index bfa97b28..0d59d1e2 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -254,8 +254,7 @@ impl BlockChainServer { } // Whether one of our validators proposes this slot. Drives the store's - // interval-0 attestation acceptance. The proposal itself is built and - // published one interval early — see the interval-4 block below. + // interval-0 attestation acceptance. let is_proposer = (interval == 0 && slot > 0) .then(|| self.get_our_proposer(slot)) .flatten() @@ -266,10 +265,12 @@ impl BlockChainServer { // ==== interval 0 ==== - // Block building/proposal is handled at interval 4 of the previous slot - // (see below): the proposer builds one interval early and publishes - // aligned to this slot's boundary. The only interval-0 work is the - // store tick above accepting attestations when we have a proposal. + // No actor work at interval 0. The block is published here conceptually + // (at the slot boundary), but the build+publish code path runs at + // interval 4 of the previous slot — where it also advances the store to + // this slot's interval 0 before building (see `propose_block`). The real + // interval-0 tick is then skipped by the idempotency guard above, since + // the store clock is already here. // ==== interval 1 ==== @@ -401,21 +402,29 @@ impl BlockChainServer { /// Build the target slot's block and publish it, one interval early. /// /// Runs at the previous slot's interval 4, blocking the actor for the build - /// (the expensive part is the leanVM Type-1 → Type-2 merge). The block is - /// built against the current canonical head; publication is aligned to the - /// slot boundary. If the build finishes before the slot opens we wait out - /// the remainder so the block is not published early; if it overran (the + /// (the expensive part is the leanVM Type-1 → Type-2 merge). It first + /// advances the store to the target slot's interval 0 (accepting + /// attestations) so the block is built on exactly the interval-0 state a + /// non-prebuilding proposer would see, then builds and publishes — aligned + /// to the slot boundary: if the build finishes before the slot opens we wait + /// out the remainder so the block is not published early; if it overran (the /// common case under load) we publish at once. The whole proposal is /// self-contained here, so it never depends on the interval-0 tick — which /// `handle_tick` skips whenever this build overruns its interval. async fn propose_block(&mut self, slot: u64, validator_id: u64) { info!(%slot, %validator_id, "We are the proposer for this slot"); - // Build against the current canonical head, READ-ONLY. We must not use - // `get_proposal_head` here: it ticks the store to `slot` time, which at - // interval 4 is one interval early and would skew finalization. The - // interval-4 promote has already run in `store::on_tick` this tick, so - // `store.head()` reflects the latest accepted attestations. + let genesis_time_ms = self.store.config().genesis_time * 1000; + let slot_start_ms = genesis_time_ms + slot * MILLISECONDS_PER_SLOT; + + // Advance the store to this slot's interval 0 — one interval ahead of the + // interval-4 tick we are running in — accepting attestations exactly as + // the real interval-0 tick would, so the block is built on the interval-0 + // state rather than the previous slot's end state. Building early is safe + // because we publish below (nothing is stashed for a later tick), and the + // real interval-0 tick is then skipped by the idempotency guard in + // `on_tick`, since the store clock is already here. + store::on_tick(&mut self.store, slot_start_ms, true); let parent_root = self.store.head(); let Some((signed_block, built_justified_slot)) = @@ -427,9 +436,7 @@ impl BlockChainServer { // Align publication to the slot boundary. If the build finished before // the slot opened, wait out the remainder so the block is not published // early; if it overran, publish immediately. - let genesis_time_ms = self.store.config().genesis_time * 1000; if !build_overran_publish_window(unix_now_ms(), genesis_time_ms, slot) { - let slot_start_ms = genesis_time_ms + slot * MILLISECONDS_PER_SLOT; let wait_ms = slot_start_ms.saturating_sub(unix_now_ms()); tokio::time::sleep(Duration::from_millis(wait_ms)).await; } diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index f5598c70..69953138 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -255,11 +255,6 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() /// slot = store.time() / INTERVALS_PER_SLOT /// interval = store.time() % INTERVALS_PER_SLOT pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { - // `has_proposal` currently has no effect: it gated the interval-0 attestation - // accept that is now disabled (see the interval 0 arm below). Kept in the - // signature so re-enabling that path needs no call-site change. - let _ = has_proposal; - // Convert UNIX timestamp (ms) to interval count since genesis let genesis_time_ms = store.config().genesis_time * 1000; let time_delta_ms = timestamp_ms.saturating_sub(genesis_time_ms); @@ -279,6 +274,10 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { trace!(%slot, %interval, "processing tick"); + // has_proposal is only signaled for the final tick (matching Python spec behavior) + let is_final_tick = store.time() == time; + let should_signal_proposal = has_proposal && is_final_tick; + // NOTE: here we assume on_tick never skips intervals. // Interval 2 (committee-signature aggregation) is no longer handled here: // the blockchain actor orchestrates the aggregation worker directly so @@ -286,25 +285,10 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool) { // proofs. See `BlockChainServer::start_aggregation_session` in `lib.rs`. match interval { 0 => { - // Interval-0 attestation acceptance is intentionally disabled. - // - // It used to promote pending attestations (new -> known) and - // refresh the head at the start of the slot, so the proposer's - // block — built at interval 0 — closed over the freshest votes. - // The proposer now builds and publishes its block at interval 4 - // of the PREVIOUS slot, so by the time we reach interval 0 the - // block is already out. Promoting here would accept attestations - // only AFTER the block was built: too late to be included, and - // it would diverge the proposer's fork-choice view from the one - // its block closed over. We want the accept immediately BEFORE - // the build, which is exactly what the unconditional interval-4 - // accept below does — it runs at the top of the same tick, just - // before `propose_block`. (`has_proposal` is thus no longer read.) - // - // let is_final_tick = store.time() == time; - // if has_proposal && is_final_tick { - // accept_new_attestations(store, false); - // } + // Start of slot - process attestations if proposal exists + if should_signal_proposal { + accept_new_attestations(store, false); + } } 1 => { // Vote propagation — no action