Skip to content

Commit d675c18

Browse files
apollo_staking: bound next-epoch resolution window by the epoch length
Heights were resolved to "current epoch + 1" for up to MIN_EPOCH_LENGTH blocks past the current epoch's end. When epochs are shorter than MIN_EPOCH_LENGTH, this attributed heights of later epochs to a stale epoch ID, so epoch-gated committee changes took effect at a different height on each node. Bound the window by min(MIN_EPOCH_LENGTH, epoch_length) so the resolved epoch is always exact; behavior is unchanged for epochs of at least MIN_EPOCH_LENGTH. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5a98bd1 commit d675c18

2 files changed

Lines changed: 82 additions & 3 deletions

File tree

crates/apollo_staking/src/staking_manager.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ impl Epoch {
9696

9797
fn within_next_epoch_min_bounds(&self, height: BlockNumber) -> bool {
9898
let next_epoch_start_block = BlockNumber(self.start_block.0 + self.epoch_length);
99-
range_contains(height, next_epoch_start_block, MIN_EPOCH_LENGTH)
99+
// Only heights guaranteed to fall within the next epoch may resolve to `epoch_id + 1`.
100+
// The next epoch's length is unknown until it starts, so bound the window by the
101+
// smallest known lower bound on epoch length.
102+
let next_epoch_min_length = MIN_EPOCH_LENGTH.min(self.epoch_length);
103+
range_contains(height, next_epoch_start_block, next_epoch_min_length)
100104
}
101105
}
102106

crates/apollo_staking/src/staking_manager_test.rs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,24 @@ const EPOCH_1: Epoch = Epoch { epoch_id: 1, start_block: BlockNumber(1), epoch_l
7373
const EPOCH_2: Epoch = Epoch { epoch_id: 2, start_block: BlockNumber(1001), epoch_length: 1000 };
7474
const EPOCH_3: Epoch = Epoch { epoch_id: 3, start_block: BlockNumber(2001), epoch_length: 1000 };
7575

76+
// Epochs shorter than MIN_EPOCH_LENGTH, matching the config-backed mock contract's epochs.
77+
const SHORT_EPOCH_LENGTH: u64 = 30;
78+
const SHORT_EPOCH_4: Epoch =
79+
Epoch { epoch_id: 4, start_block: BlockNumber(120), epoch_length: SHORT_EPOCH_LENGTH };
80+
const SHORT_EPOCH_5: Epoch =
81+
Epoch { epoch_id: 5, start_block: BlockNumber(150), epoch_length: SHORT_EPOCH_LENGTH };
82+
const SHORT_EPOCH_6: Epoch =
83+
Epoch { epoch_id: 6, start_block: BlockNumber(180), epoch_length: SHORT_EPOCH_LENGTH };
84+
const SHORT_EPOCH_7: Epoch =
85+
Epoch { epoch_id: 7, start_block: BlockNumber(210), epoch_length: SHORT_EPOCH_LENGTH };
86+
7687
const E1_H1: BlockNumber = EPOCH_1.start_block;
7788
const E1_H2: BlockNumber = BlockNumber(EPOCH_1.start_block.0 + EPOCH_1.epoch_length - 1);
7889
const E2_H1: BlockNumber = EPOCH_2.start_block;
79-
const E2_H2: BlockNumber = BlockNumber(EPOCH_2.start_block.0 + MIN_EPOCH_LENGTH + 1);
90+
// The last height within the next epoch's min bounds, and the first height past them.
91+
const E2_H_LAST_WITHIN_MIN_BOUNDS: BlockNumber =
92+
BlockNumber(EPOCH_2.start_block.0 + MIN_EPOCH_LENGTH - 1);
93+
const E2_H2: BlockNumber = BlockNumber(EPOCH_2.start_block.0 + MIN_EPOCH_LENGTH);
8094
const E3_H1: BlockNumber = EPOCH_3.start_block;
8195

8296
fn test_config_with_committee_size(committee_size: usize) -> StakingManagerDynamicConfig {
@@ -362,7 +376,10 @@ async fn get_committee_for_next_epoch(
362376
let committee = committee_manager.get_committee(E2_H1).await.unwrap().members().clone();
363377
assert_eq!(committee.into_iter().collect::<HashSet<_>>(), HashSet::from([STAKER_1, STAKER_2]));
364378

365-
// 2. Invalid Query: E2_H2 exceeds the min bounds of the next epoch.
379+
// 2. Valid Query: the last height within the next epoch's min bounds.
380+
assert!(committee_manager.get_committee(E2_H_LAST_WITHIN_MIN_BOUNDS).await.is_ok());
381+
382+
// 3. Invalid Query: E2_H2 exceeds the min bounds of the next epoch.
366383
// Since the next epoch's length is not known at this point, we cannot know if this height
367384
// belongs to Epoch 2 or a future Epoch > 2.
368385
let err = committee_manager.get_committee(E2_H2).await.err().unwrap();
@@ -936,3 +953,61 @@ async fn get_actual_proposer_invalid_height(
936953
let err = committee_manager.get_committee(E2_H2).await.err().unwrap();
937954
assert_matches!(err, CommitteeProviderError::InvalidHeight { .. });
938955
}
956+
957+
#[rstest]
958+
#[tokio::test]
959+
async fn get_committee_short_epochs_override_activates_at_start_epoch(
960+
default_config: StakingManagerConfig,
961+
mut contract: MockStakingContract,
962+
) {
963+
// Regression test: with epochs shorter than MIN_EPOCH_LENGTH, a node whose epoch cache
964+
// was synced a few epochs before an override's start_epoch must still apply the override
965+
// exactly from the override's first height, rather than serving the default committee
966+
// under a stale epoch ID.
967+
set_current_epoch(&mut contract, SHORT_EPOCH_5);
968+
set_current_epoch(&mut contract, SHORT_EPOCH_6);
969+
set_previous_epoch(&mut contract, Some(SHORT_EPOCH_4));
970+
set_stakers(&mut contract, SHORT_EPOCH_6, vec![STAKER_1, STAKER_2, STAKER_3]);
971+
set_stakers(&mut contract, SHORT_EPOCH_7, vec![STAKER_1, STAKER_2, STAKER_3]);
972+
973+
let default_committee = CommitteeConfig {
974+
start_epoch: 0,
975+
committee_size: 3,
976+
stakers: vec![
977+
create_configured_staker(&STAKER_1, true),
978+
create_configured_staker(&STAKER_2, true),
979+
create_configured_staker(&STAKER_3, true),
980+
],
981+
};
982+
// Shrink the committee to 2 members starting at epoch 7.
983+
let override_committee = Some(CommitteeConfig {
984+
start_epoch: SHORT_EPOCH_7.epoch_id,
985+
committee_size: 2,
986+
stakers: vec![
987+
create_configured_staker(&STAKER_2, true),
988+
create_configured_staker(&STAKER_3, true),
989+
],
990+
});
991+
992+
let committee_manager = StakingManager::new(
993+
Arc::new(contract),
994+
Arc::new(create_batcher_client_with_block_hash()),
995+
Arc::new(create_state_sync_client_with_block_hash()),
996+
Arc::new(make_generator_factory(0)),
997+
StakingManagerConfig {
998+
dynamic_config: StakingManagerDynamicConfig { default_committee, override_committee },
999+
..default_config
1000+
},
1001+
None,
1002+
);
1003+
1004+
// The last height of epoch 6 primes the epoch cache at epoch 5 and still gets the
1005+
// default committee.
1006+
let last_height_before_override = BlockNumber(SHORT_EPOCH_7.start_block.0 - 1);
1007+
let committee = committee_manager.get_committee(last_height_before_override).await.unwrap();
1008+
assert_eq!(*committee.members(), vec![STAKER_3, STAKER_2, STAKER_1]);
1009+
1010+
// The first height of epoch 7 must already get the override committee.
1011+
let committee = committee_manager.get_committee(SHORT_EPOCH_7.start_block).await.unwrap();
1012+
assert_eq!(*committee.members(), vec![STAKER_3, STAKER_2]);
1013+
}

0 commit comments

Comments
 (0)