Skip to content

Commit 1945d71

Browse files
committed
fix(fault-proof): follow genesis-rooted catch-up chains to their tip
Backport of upstream PR succinctlabs#926. After an L2 finalized-head stall plus bond claim window expiry, the proposer prunes its cache to the anchor and rebuilds the backlog as a fresh genesis-rooted chain (parent=u32::MAX). compute_canonical_head used to stop at such a chain's root instead of following it to its tip, pinning the head and stalling proposals. Extract the selection into a pure ProposerState::select_canonical_head method and add unit tests covering anchor-subtree, genesis-rooted catch-up, deep genesis chain, and earlier-lineage override cases. The tz-proposer shares ProposerState with the main proposer, so the fix benefits tz flows automatically.
1 parent b471a2c commit 1945d71

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
@@ -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

Comments
 (0)