@@ -27,8 +27,21 @@ use summit_types::consensus_state::ConsensusState;
2727use summit_types:: { Block , Digest } ;
2828use 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.
3132fn 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