@@ -225,6 +225,55 @@ impl ProposerState {
225225 self . games . remove ( & index) ;
226226 }
227227 }
228+
229+ /// Selects the canonical head: the highest-L2-block game on the best valid chain.
230+ ///
231+ /// With no anchor, the head is simply the highest game in the cache. With an anchor, the head
232+ /// is the highest descendant of the anchor, unless a higher chain branches off earlier
233+ /// (genesis-rooted, or a lower parent index than the anchor head) — that alternative chain is
234+ /// then followed to its own tip.
235+ fn select_canonical_head ( & self ) -> Option < Game > {
236+ let Some ( anchor_game) = self . anchor_game . as_ref ( ) else {
237+ return self . games . values ( ) . max_by_key ( |g| g. l2_block ) . cloned ( ) ;
238+ } ;
239+
240+ let reachable = self . descendants_of ( anchor_game. index ) ;
241+
242+ // Best among the anchor's descendants.
243+ let anchor_head = self
244+ . games
245+ . values ( )
246+ . filter ( |g| reachable. contains ( & g. index ) )
247+ . max_by_key ( |g| g. l2_block )
248+ . cloned ( ) ;
249+
250+ // Override with a higher non-descendant chain that branches off earlier than the anchor
251+ // head (genesis-rooted, or a lower parent index). Such a chain's root sits outside the
252+ // anchor's subtree, so we follow each qualifying root to its own highest-block tip rather
253+ // than stopping at the root — otherwise the head would pin to the root of a genesis-rooted
254+ // catch-up chain and stall instead of tracking its tip.
255+ let override_head = anchor_head. as_ref ( ) . and_then ( |anchor| {
256+ let roots: Vec < U256 > = self
257+ . games
258+ . values ( )
259+ . filter ( |g| {
260+ !reachable. contains ( & g. index ) &&
261+ g. l2_block > anchor. l2_block &&
262+ ( g. parent_index == u32:: MAX || g. parent_index < anchor. parent_index )
263+ } )
264+ . map ( |g| g. index )
265+ . collect ( ) ;
266+
267+ roots
268+ . into_iter ( )
269+ . flat_map ( |root| self . descendants_of ( root) )
270+ . filter_map ( |idx| self . games . get ( & idx) )
271+ . max_by_key ( |g| g. l2_block )
272+ . cloned ( )
273+ } ) ;
274+
275+ override_head. or ( anchor_head)
276+ }
228277}
229278
230279/// Snapshot of the proposer's cached state for testing and monitoring.
@@ -1094,44 +1143,11 @@ where
10941143 Ok ( ( ) )
10951144 }
10961145
1097- /// Computes the canonical head by scanning all cached games.
1098- ///
1099- /// Canonical head is the game with the highest L2 block number. When an anchor game exists,
1100- /// the canonical head is chosen from its descendants, unless a non-descendant has a higher L2
1101- /// block number and an earlier lineage (parent is genesis or has a lower parent index than the
1102- /// best descendant).
1146+ /// Computes and stores the canonical head used to schedule new proposals, logging on change.
11031147 async fn compute_canonical_head ( & self ) {
11041148 let mut state = self . state . write ( ) . await ;
11051149
1106- let canonical_head = match state. anchor_game . as_ref ( ) {
1107- None => state. games . values ( ) . max_by_key ( |g| g. l2_block ) . cloned ( ) ,
1108- Some ( anchor_game) => {
1109- let reachable = state. descendants_of ( anchor_game. index ) ;
1110-
1111- // Best among descendants
1112- let anchor_head = state
1113- . games
1114- . values ( )
1115- . filter ( |g| reachable. contains ( & g. index ) )
1116- . max_by_key ( |g| g. l2_block ) ;
1117-
1118- // Check non-descendants for override (higher block with genesis or lower parent)
1119- let override_head = anchor_head. and_then ( |anchor| {
1120- state
1121- . games
1122- . values ( )
1123- . filter ( |g| !reachable. contains ( & g. index ) )
1124- . filter ( |g| {
1125- g. l2_block > anchor. l2_block &&
1126- ( g. parent_index == u32:: MAX ||
1127- g. parent_index < anchor. parent_index )
1128- } )
1129- . max_by_key ( |g| g. l2_block )
1130- } ) ;
1131-
1132- override_head. or ( anchor_head) . cloned ( )
1133- }
1134- } ;
1150+ let canonical_head = state. select_canonical_head ( ) ;
11351151
11361152 let previous_canonical_index = state. canonical_head_index ;
11371153
@@ -2698,6 +2714,93 @@ mod tests {
26982714 use rstest:: rstest;
26992715 use std:: time:: Duration ;
27002716
2717+ mod canonical_head {
2718+ use super :: super :: { Game , ProposerState } ;
2719+ use crate :: contract:: { GameStatus , ProposalStatus } ;
2720+ use alloy_primitives:: { Address , B256 , U256 } ;
2721+
2722+ fn game_with ( index : u64 , parent_index : u32 , l2_block : u64 ) -> Game {
2723+ Game {
2724+ index : U256 :: from ( index) ,
2725+ address : Address :: left_padding_from ( & [ index as u8 ] ) ,
2726+ parent_index,
2727+ l2_block : U256 :: from ( l2_block) ,
2728+ status : GameStatus :: IN_PROGRESS ,
2729+ proposal_status : ProposalStatus :: Unchallenged ,
2730+ deadline : 0 ,
2731+ should_attempt_to_resolve : false ,
2732+ should_attempt_to_claim_bond : false ,
2733+ aggregation_vkey : B256 :: ZERO ,
2734+ range_vkey_commitment : B256 :: ZERO ,
2735+ rollup_config_hash : B256 :: ZERO ,
2736+ }
2737+ }
2738+
2739+ fn state ( games : Vec < Game > , anchor : Option < Game > ) -> ProposerState {
2740+ ProposerState {
2741+ games : games. into_iter ( ) . map ( |g| ( g. index , g) ) . collect ( ) ,
2742+ anchor_game : anchor,
2743+ ..Default :: default ( )
2744+ }
2745+ }
2746+
2747+ #[ test]
2748+ fn no_anchor_selects_highest_block_game ( ) {
2749+ let s = state ( vec ! [ game_with( 0 , u32 :: MAX , 100 ) , game_with( 1 , 0 , 200 ) ] , None ) ;
2750+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 1 ) ) ;
2751+ }
2752+
2753+ #[ test]
2754+ fn anchor_subtree_selects_its_tip ( ) {
2755+ let anchor = game_with ( 5 , 4 , 100 ) ;
2756+ let s = state (
2757+ vec ! [ anchor. clone( ) , game_with( 6 , 5 , 200 ) , game_with( 7 , 6 , 300 ) ] ,
2758+ Some ( anchor) ,
2759+ ) ;
2760+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 7 ) ) ;
2761+ }
2762+
2763+ #[ test]
2764+ fn genesis_rooted_catchup_chain_follows_to_tip ( ) {
2765+ // Anchor 5 was the canonical head; the proposer could not parent on it (the contract
2766+ // reverts when the parent is the anchor), so it built a fresh genesis-rooted chain
2767+ // 6 <- 7 above it. The head must track the chain tip (7), not its root (6).
2768+ let anchor = game_with ( 5 , 4 , 100 ) ;
2769+ let s = state (
2770+ vec ! [ anchor. clone( ) , game_with( 6 , u32 :: MAX , 200 ) , game_with( 7 , 6 , 300 ) ] ,
2771+ Some ( anchor) ,
2772+ ) ;
2773+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 7 ) ) ;
2774+ }
2775+
2776+ #[ test]
2777+ fn genesis_rooted_deep_chain_follows_to_tip ( ) {
2778+ let anchor = game_with ( 5 , 4 , 100 ) ;
2779+ let s = state (
2780+ vec ! [
2781+ anchor. clone( ) ,
2782+ game_with( 6 , u32 :: MAX , 200 ) ,
2783+ game_with( 7 , 6 , 300 ) ,
2784+ game_with( 8 , 7 , 400 ) ,
2785+ ] ,
2786+ Some ( anchor) ,
2787+ ) ;
2788+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 8 ) ) ;
2789+ }
2790+
2791+ #[ test]
2792+ fn earlier_lineage_override_follows_to_tip ( ) {
2793+ // A chain branching off an earlier lineage than the anchor (parent 2 < anchor parent 4)
2794+ // must also be followed to its tip.
2795+ let anchor = game_with ( 5 , 4 , 100 ) ;
2796+ let s = state (
2797+ vec ! [ anchor. clone( ) , game_with( 6 , 2 , 200 ) , game_with( 7 , 6 , 300 ) ] ,
2798+ Some ( anchor) ,
2799+ ) ;
2800+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 7 ) ) ;
2801+ }
2802+ }
2803+
27012804 async fn mock_prove (
27022805 idx : usize ,
27032806 range : ( u64 , u64 ) ,
0 commit comments