Skip to content

Commit 37a9e03

Browse files
committed
fix(fault-proof): follow genesis-rooted catch-up chains to their tip
After a long L2 finalized-head stall, the proposer can prune its cache down to the anchor game and rebuild the backlog as a genesis-rooted chain (parent = u32::MAX, since the contract reverts when a new game's parent is the current anchor). compute_canonical_head pinned the head to the root of that alternative chain instead of following it to the tip, so the canonical head could not advance and game creation stalled until the anchor caught up. Follow each qualifying alternative-chain root (genesis-rooted, or a lower parent index than the anchor head) to its highest-block tip. Extract the selection into ProposerState::select_canonical_head and cover it with unit tests.
1 parent 0e070c4 commit 37a9e03

1 file changed

Lines changed: 138 additions & 35 deletions

File tree

fault-proof/src/proposer.rs

Lines changed: 138 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)