Skip to content

Commit 05d0b07

Browse files
fix: reject epoch-mismatched blocks before EL forkchoice adoption
1 parent 7f9432a commit 05d0b07

2 files changed

Lines changed: 129 additions & 4 deletions

File tree

finalizer/src/actor.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ impl<
745745
// canonical height; the finalized path must do the same. We must still ACK the
746746
// duplicate so the syncer's pending-ack pipeline doesn't stall, and we must NOT
747747
// re-execute it — re-execution would re-run the EL payload check, re-process
748-
// deposits/withdrawals, regress the height, or trip the epoch assertion in
748+
// deposits/withdrawals, regress the height, or fail the epoch check in
749749
// `execute_block` when the canonical state has already advanced past an epoch
750750
// boundary.
751751
let latest_height = self.canonical_state.get_latest_height();
@@ -2093,6 +2093,25 @@ async fn execute_block<
20932093
#[cfg(feature = "prom")]
20942094
let block_processing_start = Instant::now();
20952095

2096+
// The block's declared epoch must match the finalizer's deterministic epoch
2097+
// counter (unchanged for the duration of this call; the boundary advance runs
2098+
// in the finalized-block handler, not here). Verify binds this on the notarized
2099+
// path, but this function also executes certified blocks the local node never
2100+
// verified (finalized catch up), so recheck it here, BEFORE check_payload and
2101+
// the EL forkchoice adoption. A mismatch is fail stop territory (a byzantine
2102+
// 2/3+1 quorum or an epoch computation bug): route it through the InvalidPayload
2103+
// policy so the node rejects cleanly instead of panicking after the EL already
2104+
// adopted the block.
2105+
if block.epoch() != state.get_epoch() {
2106+
warn!(
2107+
height = block.height(),
2108+
block_epoch = block.epoch(),
2109+
state_epoch = state.get_epoch(),
2110+
"block epoch does not match consensus state epoch; rejecting"
2111+
);
2112+
return Ok(ExecuteOutcome::InvalidPayload);
2113+
}
2114+
20962115
// check the payload
20972116
#[cfg(feature = "prom")]
20982117
let payload_check_start = Instant::now();
@@ -2214,7 +2233,10 @@ async fn execute_block<
22142233
state.set_latest_height(new_height);
22152234
state.set_view(block.view());
22162235
state.set_head_digest(block.digest());
2217-
assert_eq!(block.epoch(), state.get_epoch());
2236+
// Guaranteed by the epoch check at the top of this function; the boundary
2237+
// advance runs in the finalized-block handler, not here, so the epoch is
2238+
// unchanged across execution.
2239+
debug_assert_eq!(block.epoch(), state.get_epoch());
22182240

22192241
// Periodically persist state to database as a blob
22202242
// We build the checkpoint one height before the epoch end which

finalizer/src/tests/fork_handling.rs

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,21 @@ use summit_types::consensus_state::ConsensusState;
2727
use summit_types::{Block, Digest};
2828
use tokio_util::sync::CancellationToken;
2929

30-
/// Helper to create a test block with specific parent and height
30+
/// Helper to create a test block with specific parent and height. Epoch is
31+
/// derived from the height (`height / 10`) to match the default epocher length.
3132
fn create_test_block(parent_digest: Digest, height: u64, view: u64, unique_seed: u64) -> Block {
33+
create_test_block_with_epoch(parent_digest, height, height / 10, view, unique_seed)
34+
}
35+
36+
/// Like [`create_test_block`] but with an explicit epoch, so tests can build a
37+
/// block whose declared epoch disagrees with its height.
38+
fn create_test_block_with_epoch(
39+
parent_digest: Digest,
40+
height: u64,
41+
epoch: u64,
42+
view: u64,
43+
unique_seed: u64,
44+
) -> Block {
3245
let mut block_hash = [0u8; 32];
3346
block_hash[0..8].copy_from_slice(&unique_seed.to_le_bytes());
3447
block_hash[8..16].copy_from_slice(&height.to_le_bytes());
@@ -69,7 +82,7 @@ fn create_test_block(parent_digest: Digest, height: u64, view: u64, unique_seed:
6982
height * 12,
7083
payload,
7184
Vec::new(),
72-
height / 10,
85+
epoch,
7386
view,
7487
None,
7588
[0u8; 32].into(),
@@ -1508,3 +1521,93 @@ fn test_competing_fork_pruned_on_finalization() {
15081521
context.auditor().state()
15091522
});
15101523
}
1524+
1525+
// A finalized block whose declared epoch disagrees with the finalizer's
1526+
// deterministic epoch counter must be rejected as InvalidPayload BEFORE the EL
1527+
// forkchoice is committed, not caught by an assert after the EL already adopted
1528+
// the block. Reachable only via a Byzantine-certified block or an epoch
1529+
// computation bug, but the node must fail-stop cleanly (no EL adoption, no
1530+
// panic).
1531+
#[test]
1532+
fn test_finalized_epoch_mismatch_rejected_before_el_adoption() {
1533+
let cfg = deterministic::Config::default().with_seed(42);
1534+
let executor = Runner::from(cfg);
1535+
executor.start(|context| async move {
1536+
let genesis_hash = [0x42u8; 32];
1537+
let initial_state = create_test_initial_state(genesis_hash, NonZeroU64::new(10).unwrap());
1538+
1539+
let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100);
1540+
let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx);
1541+
1542+
let node_key = ed25519::PrivateKey::from_seed(0);
1543+
let engine_client = MockEngineClient::new();
1544+
let engine_probe = engine_client.clone();
1545+
let cancellation_token = CancellationToken::new();
1546+
1547+
let finalizer_cfg = FinalizerConfig::<MockEngineClient, MockNetworkOracle, MinPk> {
1548+
mailbox_size: 100,
1549+
db_prefix: "test_finalized_epoch_mismatch".to_string(),
1550+
engine_client,
1551+
oracle: MockNetworkOracle,
1552+
protocol_consts: ProtocolConsts {
1553+
validator_num_warm_up_epochs: 2,
1554+
validator_withdrawal_num_epochs: 2,
1555+
},
1556+
1557+
page_cache: CacheRef::from_pooler(
1558+
&context,
1559+
std::num::NonZero::new(4096).unwrap(),
1560+
NZUsize!(100),
1561+
),
1562+
genesis_hash,
1563+
initial_state,
1564+
protocol_version: 1,
1565+
node_public_key: node_key.public_key(),
1566+
cancellation_token: cancellation_token.clone(),
1567+
drain_interval: Duration::from_millis(100),
1568+
buffered_blocks_warn_threshold: 100,
1569+
pending_notarized_max: 1000,
1570+
namespace: Vec::new(),
1571+
observer_domain: Vec::new(),
1572+
_variant_marker: PhantomData,
1573+
};
1574+
1575+
let (finalizer, _state, mut mailbox, _state_query) =
1576+
Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new(
1577+
context.with_label("finalizer"),
1578+
finalizer_cfg,
1579+
)
1580+
.await;
1581+
1582+
let _handle = finalizer.start(orchestrator_mailbox);
1583+
context.sleep(Duration::from_millis(100)).await;
1584+
1585+
// Height 1 extends the canonical head (parent == genesis) so it is a
1586+
// contiguous finalized block that reaches execute_block, but its declared
1587+
// epoch is 1 while the finalizer is still at epoch 0.
1588+
let genesis_block = Block::genesis(genesis_hash);
1589+
let bad = create_test_block_with_epoch(genesis_block.digest(), 1, 1, 1, 7777);
1590+
assert_eq!(bad.epoch(), 1, "test block must declare a mismatched epoch");
1591+
1592+
let (ack, _waiter) = Exact::handle();
1593+
mailbox
1594+
.report(Update::FinalizedBlock((bad, None), ack))
1595+
.await;
1596+
context.sleep(Duration::from_millis(300)).await;
1597+
1598+
assert!(
1599+
cancellation_token.is_cancelled(),
1600+
"an epoch-mismatched finalized block must fail-stop the node"
1601+
);
1602+
// The epoch check precedes check_payload and the forkchoice commit, and
1603+
// startup never calls check_payload, so a zero count proves the block was
1604+
// rejected before any EL interaction (no adoption, no panic-after-commit).
1605+
assert_eq!(
1606+
engine_probe.check_payload_call_count(),
1607+
0,
1608+
"the block must be rejected before the EL sees it"
1609+
);
1610+
1611+
context.auditor().state()
1612+
});
1613+
}

0 commit comments

Comments
 (0)