From c40f3ea0db0db9473967df4aacc55914e1a8c09c 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, 29 Jun 2026 11:59:15 -0300 Subject: [PATCH 1/2] feat(blockchain): warn instead of failing on unclosed justified divergence When a produced block's justified slot lagged the store's justified checkpoint, `produce_block_with_signatures` returned `JustifiedDivergenceNotClosed`, which aborted block production entirely. In practice this halts the chain: the proposer cannot re-justify the head because validators source from the store-justified checkpoint, so every subsequent proposal hits the same divergence and freezes block production. Halting production is worse than publishing a block whose justified slot lags by one fork: the latter still lets the chain make progress and gives pool attestations a chance to close the divergence on later slots. Replace the hard error with a warn-level log and publish the block anyway. --- crates/blockchain/src/store.rs | 32 ++++++++----------- crates/blockchain/state_transition/src/lib.rs | 4 +-- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 65bec555..0d7b6064 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -825,17 +825,23 @@ pub fn produce_block_with_signatures( )? }; - // Invariant (leanSpec #595): the produced block must not lag the store's - // justified checkpoint. Otherwise peers processing this block would never - // see justification advance, degrading liveness: the fixed-point loop in - // `build_block` is expected to incorporate pool attestations that close - // any divergence inherited from a minority fork. + // leanSpec #595: ideally the produced block should not lag the store's + // justified checkpoint, since peers processing it would not see + // justification advance, degrading liveness. The fixed-point loop in + // `build_block` is expected to incorporate pool attestations that close any + // divergence inherited from a minority fork, but it may not always + // converge. We still publish the block in that case (halting block + // production freezes the chain, which is worse) and only log the divergence. let store_justified_slot = store.latest_justified().slot; if post_checkpoints.justified.slot < store_justified_slot { - return Err(StoreError::JustifiedDivergenceNotClosed { - block_justified_slot: post_checkpoints.justified.slot, + warn!( + %slot, + proposer = validator_index, + block_justified_slot = post_checkpoints.justified.slot, store_justified_slot, - }); + "Produced block justified slot is behind store justified slot; \ + fixed-point attestation loop did not converge" + ); } metrics::observe_block_aggregated_payloads(signatures.len()); @@ -942,16 +948,6 @@ pub enum StoreError { #[error("Block contains {count} distinct AttestationData entries; maximum is {max}")] TooManyAttestationData { count: usize, max: usize }, - - #[error( - "Produced block justified slot {block_justified_slot} \ - is behind store justified slot {store_justified_slot}; \ - fixed-point attestation loop did not converge" - )] - JustifiedDivergenceNotClosed { - block_justified_slot: u64, - store_justified_slot: u64, - }, } /// Full verification of a signed block's merged Type-2 proof. diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index ac57bc7d..491fac7c 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -710,8 +710,8 @@ mod tests { /// unconditional `state.latest_justified = target` assignment caused the /// post-state to end at `latest_justified.slot = 27974`. Because the /// store had already latched `latest_justified = 27978` from importing a - /// fork block, every subsequent proposal failed - /// `JustifiedDivergenceNotClosed` and the chain froze. + /// fork block, every subsequent proposal produced a block whose justified + /// slot lagged the store and the chain froze. /// /// Compressed setup: finalized=0, source=3 (justified), targets in body /// order 4 / 9 / 6 — all justifiable from finalized=0 (Δ=4 ≤ 5, Δ=9=3², From e421baf206466911c30a225bf2532b51b9691b61 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, 29 Jun 2026 13:08:35 -0300 Subject: [PATCH 2/2] docs: revert test doc change --- crates/blockchain/state_transition/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 491fac7c..ac57bc7d 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -710,8 +710,8 @@ mod tests { /// unconditional `state.latest_justified = target` assignment caused the /// post-state to end at `latest_justified.slot = 27974`. Because the /// store had already latched `latest_justified = 27978` from importing a - /// fork block, every subsequent proposal produced a block whose justified - /// slot lagged the store and the chain froze. + /// fork block, every subsequent proposal failed + /// `JustifiedDivergenceNotClosed` and the chain froze. /// /// Compressed setup: finalized=0, source=3 (justified), targets in body /// order 4 / 9 / 6 — all justifiable from finalized=0 (Δ=4 ≤ 5, Δ=9=3²,