@@ -226,6 +226,55 @@ impl ProposerState {
226226 self . games . remove ( & index) ;
227227 }
228228 }
229+
230+ /// Selects the canonical head: the highest-L2-block game on the best valid chain.
231+ ///
232+ /// With no anchor, the head is simply the highest game in the cache. With an anchor, the head
233+ /// is the highest descendant of the anchor, unless a higher chain branches off earlier
234+ /// (genesis-rooted, or a lower parent index than the anchor head) — that alternative chain is
235+ /// then followed to its own tip.
236+ fn select_canonical_head ( & self ) -> Option < Game > {
237+ let Some ( anchor_game) = self . anchor_game . as_ref ( ) else {
238+ return self . games . values ( ) . max_by_key ( |g| g. l2_block ) . cloned ( ) ;
239+ } ;
240+
241+ let reachable = self . descendants_of ( anchor_game. index ) ;
242+
243+ // Best among the anchor's descendants.
244+ let anchor_head = self
245+ . games
246+ . values ( )
247+ . filter ( |g| reachable. contains ( & g. index ) )
248+ . max_by_key ( |g| g. l2_block )
249+ . cloned ( ) ;
250+
251+ // Override with a higher non-descendant chain that branches off earlier than the anchor
252+ // head (genesis-rooted, or a lower parent index). Such a chain's root sits outside the
253+ // anchor's subtree, so we follow each qualifying root to its own highest-block tip rather
254+ // than stopping at the root — otherwise the head would pin to the root of a genesis-rooted
255+ // catch-up chain and stall instead of tracking its tip.
256+ let override_head = anchor_head. as_ref ( ) . and_then ( |anchor| {
257+ let roots: Vec < U256 > = self
258+ . games
259+ . values ( )
260+ . filter ( |g| {
261+ !reachable. contains ( & g. index ) &&
262+ g. l2_block > anchor. l2_block &&
263+ ( g. parent_index == u32:: MAX || g. parent_index < anchor. parent_index )
264+ } )
265+ . map ( |g| g. index )
266+ . collect ( ) ;
267+
268+ roots
269+ . into_iter ( )
270+ . flat_map ( |root| self . descendants_of ( root) )
271+ . filter_map ( |idx| self . games . get ( & idx) )
272+ . max_by_key ( |g| g. l2_block )
273+ . cloned ( )
274+ } ) ;
275+
276+ override_head. or ( anchor_head)
277+ }
229278}
230279
231280/// Snapshot of the proposer's cached state for testing and monitoring.
@@ -1082,44 +1131,11 @@ where
10821131 Ok ( ( ) )
10831132 }
10841133
1085- /// Computes the canonical head by scanning all cached games.
1086- ///
1087- /// Canonical head is the game with the highest L2 block number. When an anchor game exists,
1088- /// the canonical head is chosen from its descendants, unless a non-descendant has a higher L2
1089- /// block number and an earlier lineage (parent is genesis or has a lower parent index than the
1090- /// best descendant).
1134+ /// Computes and stores the canonical head used to schedule new proposals, logging on change.
10911135 async fn compute_canonical_head ( & self ) {
10921136 let mut state = self . state . write ( ) . await ;
10931137
1094- let canonical_head = match state. anchor_game . as_ref ( ) {
1095- None => state. games . values ( ) . max_by_key ( |g| g. l2_block ) . cloned ( ) ,
1096- Some ( anchor_game) => {
1097- let reachable = state. descendants_of ( anchor_game. index ) ;
1098-
1099- // Best among descendants
1100- let anchor_head = state
1101- . games
1102- . values ( )
1103- . filter ( |g| reachable. contains ( & g. index ) )
1104- . max_by_key ( |g| g. l2_block ) ;
1105-
1106- // Check non-descendants for override (higher block with genesis or lower parent)
1107- let override_head = anchor_head. and_then ( |anchor| {
1108- state
1109- . games
1110- . values ( )
1111- . filter ( |g| !reachable. contains ( & g. index ) )
1112- . filter ( |g| {
1113- g. l2_block > anchor. l2_block &&
1114- ( g. parent_index == u32:: MAX ||
1115- g. parent_index < anchor. parent_index )
1116- } )
1117- . max_by_key ( |g| g. l2_block )
1118- } ) ;
1119-
1120- override_head. or ( anchor_head) . cloned ( )
1121- }
1122- } ;
1138+ let canonical_head = state. select_canonical_head ( ) ;
11231139
11241140 let previous_canonical_index = state. canonical_head_index ;
11251141
@@ -2676,6 +2692,93 @@ mod tests {
26762692 use rstest:: rstest;
26772693 use std:: time:: Duration ;
26782694
2695+ mod canonical_head {
2696+ use super :: super :: { Game , ProposerState } ;
2697+ use crate :: contract:: { GameStatus , ProposalStatus } ;
2698+ use alloy_primitives:: { Address , B256 , U256 } ;
2699+
2700+ fn game_with ( index : u64 , parent_index : u32 , l2_block : u64 ) -> Game {
2701+ Game {
2702+ index : U256 :: from ( index) ,
2703+ address : Address :: left_padding_from ( & [ index as u8 ] ) ,
2704+ parent_index,
2705+ l2_block : U256 :: from ( l2_block) ,
2706+ status : GameStatus :: IN_PROGRESS ,
2707+ proposal_status : ProposalStatus :: Unchallenged ,
2708+ deadline : 0 ,
2709+ should_attempt_to_resolve : false ,
2710+ should_attempt_to_claim_bond : false ,
2711+ aggregation_vkey : B256 :: ZERO ,
2712+ range_vkey_commitment : B256 :: ZERO ,
2713+ rollup_config_hash : B256 :: ZERO ,
2714+ }
2715+ }
2716+
2717+ fn state ( games : Vec < Game > , anchor : Option < Game > ) -> ProposerState {
2718+ ProposerState {
2719+ games : games. into_iter ( ) . map ( |g| ( g. index , g) ) . collect ( ) ,
2720+ anchor_game : anchor,
2721+ ..Default :: default ( )
2722+ }
2723+ }
2724+
2725+ #[ test]
2726+ fn no_anchor_selects_highest_block_game ( ) {
2727+ let s = state ( vec ! [ game_with( 0 , u32 :: MAX , 100 ) , game_with( 1 , 0 , 200 ) ] , None ) ;
2728+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 1 ) ) ;
2729+ }
2730+
2731+ #[ test]
2732+ fn anchor_subtree_selects_its_tip ( ) {
2733+ let anchor = game_with ( 5 , 4 , 100 ) ;
2734+ let s = state (
2735+ vec ! [ anchor. clone( ) , game_with( 6 , 5 , 200 ) , game_with( 7 , 6 , 300 ) ] ,
2736+ Some ( anchor) ,
2737+ ) ;
2738+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 7 ) ) ;
2739+ }
2740+
2741+ #[ test]
2742+ fn genesis_rooted_catchup_chain_follows_to_tip ( ) {
2743+ // Anchor 5 was the canonical head; the proposer could not parent on it (the contract
2744+ // reverts when the parent is the anchor), so it built a fresh genesis-rooted chain
2745+ // 6 <- 7 above it. The head must track the chain tip (7), not its root (6).
2746+ let anchor = game_with ( 5 , 4 , 100 ) ;
2747+ let s = state (
2748+ vec ! [ anchor. clone( ) , game_with( 6 , u32 :: MAX , 200 ) , game_with( 7 , 6 , 300 ) ] ,
2749+ Some ( anchor) ,
2750+ ) ;
2751+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 7 ) ) ;
2752+ }
2753+
2754+ #[ test]
2755+ fn genesis_rooted_deep_chain_follows_to_tip ( ) {
2756+ let anchor = game_with ( 5 , 4 , 100 ) ;
2757+ let s = state (
2758+ vec ! [
2759+ anchor. clone( ) ,
2760+ game_with( 6 , u32 :: MAX , 200 ) ,
2761+ game_with( 7 , 6 , 300 ) ,
2762+ game_with( 8 , 7 , 400 ) ,
2763+ ] ,
2764+ Some ( anchor) ,
2765+ ) ;
2766+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 8 ) ) ;
2767+ }
2768+
2769+ #[ test]
2770+ fn earlier_lineage_override_follows_to_tip ( ) {
2771+ // A chain branching off an earlier lineage than the anchor (parent 2 < anchor parent 4)
2772+ // must also be followed to its tip.
2773+ let anchor = game_with ( 5 , 4 , 100 ) ;
2774+ let s = state (
2775+ vec ! [ anchor. clone( ) , game_with( 6 , 2 , 200 ) , game_with( 7 , 6 , 300 ) ] ,
2776+ Some ( anchor) ,
2777+ ) ;
2778+ assert_eq ! ( s. select_canonical_head( ) . unwrap( ) . index, U256 :: from( 7 ) ) ;
2779+ }
2780+ }
2781+
26792782 async fn mock_prove (
26802783 idx : usize ,
26812784 range : ( u64 , u64 ) ,
0 commit comments