Skip to content

Commit c5616d6

Browse files
qj0r9j0vc2claude
andcommitted
fix(consensus): compute proposer advances from genesis for determinism
Nodes starting at different heights (e.g., after syncing) were computing different numbers of advances to reach the same (height, round), causing proposer selection disagreement and consensus stalls. The fix ensures all nodes compute advances from a fixed genesis point (height 1, round Nil) on their first select_proposer call, regardless of their initial_height. This guarantees deterministic proposer selection across all nodes in the network. Also fixes a test in execution_bridge.rs that wasn't destructuring the tuple returned by create_default_bridge(). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ade5603 commit c5616d6

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

crates/consensus/src/proposer_selector.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ impl ProposerSelector {
6363
///
6464
/// This method advances the internal priority state to match the target
6565
/// (height, round) and returns the validator with highest priority.
66+
///
67+
/// **IMPORTANT**: To ensure all nodes agree on the proposer regardless of their
68+
/// starting point (initial sync vs. running from genesis), advances are computed
69+
/// from the ABSOLUTE position (genesis height 1, round Nil), not from the node's
70+
/// `initial_height`. This ensures deterministic proposer selection across all nodes.
6671
pub fn select_proposer<'a>(
6772
&self,
6873
validator_set: &'a ConsensusValidatorSet,
@@ -76,8 +81,17 @@ impl ProposerSelector {
7681

7782
let (last_height, last_round) = *last_synced;
7883

79-
// Calculate total advances needed from last synced point to (height, round)
80-
let advances = Self::compute_advances(last_height, last_round, height, round);
84+
// Calculate total advances needed from last synced point to (height, round).
85+
// If this is the first call (last_synced is at initial_height with Nil round),
86+
// we compute advances from GENESIS (height 1, Nil) to ensure all nodes get
87+
// the same result regardless of their starting height.
88+
let advances = if last_round == Round::Nil && last_proposer.is_none() {
89+
// First call: compute absolute advances from genesis
90+
Self::compute_advances(ConsensusHeight(1), Round::Nil, height, round)
91+
} else {
92+
// Subsequent calls: compute relative advances from last synced point
93+
Self::compute_advances(last_height, last_round, height, round)
94+
};
8195

8296
// Track the proposer from advances (the proposer is determined DURING advance,
8397
// not after, because advance_one_round penalizes the proposer)
@@ -461,4 +475,52 @@ mod tests {
461475
);
462476
assert_eq!(advances, 4, "(1, 0) -> (5, 0) should be 4 advances");
463477
}
478+
479+
#[test]
480+
fn test_different_initial_heights_agree_on_proposer() {
481+
// CRITICAL: Nodes that start at different initial heights (e.g., after syncing)
482+
// MUST agree on the proposer for the same (height, round).
483+
// This is the root cause of consensus stalls when nodes disagree on proposer.
484+
let vs = make_validator_set(&[100, 100, 100]);
485+
486+
// Node A: started from genesis (height 1)
487+
let selector_from_genesis = ProposerSelector::new(&vs, ConsensusHeight(1));
488+
489+
// Node B: synced and started at height 34 (simulating a node that joined later)
490+
let selector_synced = ProposerSelector::new(&vs, ConsensusHeight(34));
491+
492+
// Both query for the proposer at height 34, round 6
493+
let proposer_genesis = selector_from_genesis.select_proposer(&vs, ConsensusHeight(34), Round::new(6));
494+
let proposer_synced = selector_synced.select_proposer(&vs, ConsensusHeight(34), Round::new(6));
495+
496+
assert_eq!(
497+
proposer_genesis.address, proposer_synced.address,
498+
"Nodes starting at different heights MUST agree on the proposer for the same (height, round)"
499+
);
500+
}
501+
502+
#[test]
503+
fn test_synced_node_continues_correctly() {
504+
// After initial query, synced node should continue correctly for subsequent rounds
505+
let vs = make_validator_set(&[100, 100, 100]);
506+
507+
// Genesis node goes through all heights
508+
let selector_genesis = ProposerSelector::new(&vs, ConsensusHeight(1));
509+
for h in 1..34 {
510+
selector_genesis.select_proposer(&vs, ConsensusHeight(h), Round::new(0));
511+
}
512+
let gen_h34_r0 = selector_genesis.select_proposer(&vs, ConsensusHeight(34), Round::new(0));
513+
let gen_h34_r1 = selector_genesis.select_proposer(&vs, ConsensusHeight(34), Round::new(1));
514+
let gen_h34_r2 = selector_genesis.select_proposer(&vs, ConsensusHeight(34), Round::new(2));
515+
516+
// Synced node starts at height 34
517+
let selector_synced = ProposerSelector::new(&vs, ConsensusHeight(34));
518+
let sync_h34_r0 = selector_synced.select_proposer(&vs, ConsensusHeight(34), Round::new(0));
519+
let sync_h34_r1 = selector_synced.select_proposer(&vs, ConsensusHeight(34), Round::new(1));
520+
let sync_h34_r2 = selector_synced.select_proposer(&vs, ConsensusHeight(34), Round::new(2));
521+
522+
assert_eq!(gen_h34_r0.address, sync_h34_r0.address, "Must agree on round 0");
523+
assert_eq!(gen_h34_r1.address, sync_h34_r1.address, "Must agree on round 1");
524+
assert_eq!(gen_h34_r2.address, sync_h34_r2.address, "Must agree on round 2");
525+
}
464526
}

crates/node/src/execution_bridge.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ mod tests {
787787

788788
#[tokio::test]
789789
async fn test_set_genesis_block_hash() {
790-
let bridge = create_default_bridge().unwrap();
790+
let (bridge, _temp_dir) = create_default_bridge().unwrap();
791791

792792
// Initially should be B256::ZERO
793793
let initial_hash = bridge.last_block_hash.read().map(|guard| *guard).unwrap();

0 commit comments

Comments
 (0)