From f690ec7ddc43f49541a340d18c338c983d8b6f50 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 1/4] 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 4e22850fd4254a73d5bafb1622d080f65461e179 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 2/4] 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 a4dbc3fa7e85952ec55a79cef543179f0b90d9c3 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 3/4] 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 f77faf20f0181529b4c3b4f61ab1bd6526d40615 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 4/4] 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 ====