EIP=8045 impl with tests for the slashing cache

This commit is contained in:
Eitan Seri-Levi
2026-06-02 01:07:23 +03:00
parent b781227f1d
commit 9c8a577412
3 changed files with 199 additions and 1 deletions

View File

@@ -299,6 +299,67 @@ async fn proposer_slashing_duplicate_in_state() {
));
}
#[tokio::test]
async fn slashings_cache_matches_state_after_block_import() {
let db_path = tempdir().unwrap();
let store = get_store(&db_path);
let harness = get_harness(store.clone(), VALIDATOR_COUNT);
// Slash a spread of validators by importing proposer slashings into the op pool, exactly as
// they would arrive over gossip.
let slashed_validators = [0u64, 7, VALIDATOR_COUNT as u64 - 1];
for &validator_index in &slashed_validators {
let slashing = harness.make_proposer_slashing(validator_index);
let ObservationOutcome::New(verified_slashing) = harness
.chain
.verify_proposer_slashing_for_gossip(slashing)
.unwrap()
else {
panic!("slashing should verify");
};
harness.chain.import_proposer_slashing(verified_slashing);
}
// Produce and import a block that includes the slashings. This drives the production flow:
// `per_block_processing` -> `slash_validator` -> `SlashingsCache::record_validator_slashing`.
harness
.extend_chain(
1,
BlockStrategy::OnCanonicalHead,
AttestationStrategy::AllValidators,
)
.await;
let state = harness.get_current_state();
// The block processing above should have left the slashings cache initialized for the head.
assert!(
state.slashings_cache_is_initialized(),
"slashings cache should be initialized after block import"
);
// The targeted validators must actually be slashed in the state (i.e. the slashings were
// included and applied, not silently dropped).
for &validator_index in &slashed_validators {
assert!(
state
.get_validator(validator_index as usize)
.unwrap()
.slashed,
"validator {validator_index} should be slashed in the state"
);
}
// The cache must agree with the `slashed` flag of *every* validator in the state.
for index in 0..state.validators().len() {
assert_eq!(
state.slashings_cache().is_slashed(index),
state.get_validator(index).unwrap().slashed,
"slashings cache disagrees with state at validator {index}"
);
}
}
#[test]
fn attester_slashing() {
let db_path = tempdir().unwrap();

View File

@@ -1394,7 +1394,20 @@ impl<E: EthSpec> BeaconState<E> {
}
// Not using the cached validator indices since they are shuffled.
let indices = self.get_active_validator_indices(epoch, spec)?;
let mut indices = self.get_active_validator_indices(epoch, spec)?;
// Post-Gloas, slashed validators are excluded from proposer selection
if self.fork_name_unchecked().gloas_enabled() {
if self.slashings_cache_is_initialized() {
let slashings_cache = self.slashings_cache();
indices.retain(|&index| !slashings_cache.is_slashed(index));
} else {
// Fallback incase the slashing cache isnt initialized for the current slot.
// The slashing cache may be cold during block replay or state reconstruction.
// This fallback makes the slashing checks infallible.
indices.retain(|&index| self.validators().get(index).is_some_and(|v| !v.slashed));
}
}
let preimage = self.get_seed(epoch, Domain::BeaconProposer, spec)?;
self.compute_proposer_indices(epoch, preimage.as_slice(), &indices, spec)

View File

@@ -64,3 +64,127 @@ impl SlashingsCache {
self.latest_block_slot = Some(latest_block_slot);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Epoch, Hash256};
use bls::PublicKeyBytes;
/// Build a minimal validator with the given `slashed` flag. The other fields are irrelevant to
/// the slashings cache.
fn validator(slashed: bool) -> Validator {
Validator {
pubkey: PublicKeyBytes::empty(),
withdrawal_credentials: Hash256::ZERO,
effective_balance: 0,
slashed,
activation_eligibility_epoch: Epoch::new(0),
activation_epoch: Epoch::new(0),
exit_epoch: Epoch::new(0),
withdrawable_epoch: Epoch::new(0),
}
}
/// Validators 1 and 3 are slashed, the rest are not.
fn validators() -> Vec<Validator> {
vec![
validator(false),
validator(true),
validator(false),
validator(true),
validator(false),
]
}
#[test]
fn new_captures_slashed_indices() {
let validators = validators();
let cache = SlashingsCache::new(Slot::new(7), validators.iter());
// The cache is initialized at the block slot it was built for.
assert!(cache.is_initialized(Slot::new(7)));
assert!(!cache.is_initialized(Slot::new(8)));
// Each index reports the same `slashed` status as the source validator.
for (index, validator) in validators.iter().enumerate() {
assert_eq!(
cache.is_slashed(index),
validator.slashed,
"validator {index} slashed status mismatch"
);
}
// An out-of-bounds index is not slashed.
assert!(!cache.is_slashed(validators.len()));
}
#[test]
fn default_is_uninitialized() {
let cache = SlashingsCache::default();
// A default cache is not initialized at any slot.
assert!(!cache.is_initialized(Slot::new(0)));
assert_eq!(
cache.check_initialized(Slot::new(0)),
Err(BeaconStateError::SlashingsCacheUninitialized {
initialized_slot: None,
latest_block_slot: Slot::new(0),
})
);
// It reports nothing as slashed. This is exactly why callers must check initialization
// before trusting `is_slashed`.
assert!(!cache.is_slashed(0));
}
#[test]
fn check_initialized_matches_block_slot() {
let cache = SlashingsCache::new(Slot::new(3), validators().iter());
assert_eq!(cache.check_initialized(Slot::new(3)), Ok(()));
assert_eq!(
cache.check_initialized(Slot::new(4)),
Err(BeaconStateError::SlashingsCacheUninitialized {
initialized_slot: Some(Slot::new(3)),
latest_block_slot: Slot::new(4),
})
);
}
#[test]
fn record_validator_slashing_requires_matching_slot() {
let mut cache = SlashingsCache::new(Slot::new(3), validators().iter());
// Index 0 starts unslashed.
assert!(!cache.is_slashed(0));
// Recording at the initialized slot succeeds and marks the validator slashed.
cache.record_validator_slashing(Slot::new(3), 0).unwrap();
assert!(cache.is_slashed(0));
// Recording at a slot the cache is not initialized for errors and leaves the set unchanged.
assert_eq!(
cache.record_validator_slashing(Slot::new(4), 2),
Err(BeaconStateError::SlashingsCacheUninitialized {
initialized_slot: Some(Slot::new(3)),
latest_block_slot: Slot::new(4),
})
);
assert!(!cache.is_slashed(2));
}
#[test]
fn update_latest_block_slot_preserves_slashed_set() {
let mut cache = SlashingsCache::new(Slot::new(3), validators().iter());
cache.update_latest_block_slot(Slot::new(4));
// The initialized slot moves forward without clearing the recorded slashings.
assert!(!cache.is_initialized(Slot::new(3)));
assert!(cache.is_initialized(Slot::new(4)));
assert!(cache.is_slashed(1));
assert!(cache.is_slashed(3));
assert!(!cache.is_slashed(0));
}
}