Gloas bid and preference verification (#9036)

Gossip verify and cache bids and proposer preferences. This PR also ensures we subscribe to new fork topics one epoch early instead of two slots early. This is required for proposer preferences.


  


Co-Authored-By: Eitan Seri- Levi <eserilev@gmail.com>
This commit is contained in:
Eitan Seri-Levi
2026-04-15 01:39:59 +09:00
committed by GitHub
parent 8c8facd0cd
commit b40a178111
19 changed files with 2267 additions and 79 deletions

View File

@@ -0,0 +1,223 @@
use std::sync::Arc;
use crate::{
BeaconChain, BeaconChainTypes, CanonicalHead,
proposer_preferences_verification::{
ProposerPreferencesError, proposer_preference_cache::GossipVerifiedProposerPreferenceCache,
},
};
use slot_clock::SlotClock;
use state_processing::signature_sets::{get_pubkey_from_state, proposer_preferences_signature_set};
use tracing::debug;
use types::{
BeaconState, ChainSpec, EthSpec, ProposerPreferences, SignedProposerPreferences, Slot,
};
/// Verify that proposer preferences are consistent with the current chain state
pub(crate) fn verify_preferences_consistency<E: EthSpec>(
preferences: &ProposerPreferences,
current_slot: Slot,
head_state: &BeaconState<E>,
) -> Result<(), ProposerPreferencesError> {
let proposal_slot = preferences.proposal_slot;
let validator_index = preferences.validator_index;
let current_epoch = current_slot.epoch(E::slots_per_epoch());
let proposal_epoch = proposal_slot.epoch(E::slots_per_epoch());
if proposal_epoch < current_epoch || proposal_epoch > current_epoch.saturating_add(1u64) {
return Err(ProposerPreferencesError::InvalidProposalEpoch { proposal_epoch });
}
if proposal_slot <= current_slot {
return Err(ProposerPreferencesError::ProposalSlotAlreadyPassed {
proposal_slot,
current_slot,
});
}
if !head_state.is_valid_proposal_slot(preferences)? {
return Err(ProposerPreferencesError::InvalidProposalSlot {
validator_index,
proposal_slot,
});
}
Ok(())
}
pub struct GossipVerificationContext<'a, T: BeaconChainTypes> {
pub canonical_head: &'a CanonicalHead<T>,
pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache,
pub slot_clock: &'a T::SlotClock,
pub spec: &'a ChainSpec,
}
/// A wrapper around `SignedProposerPreferences` that has been verified for gossip propagation.
#[derive(Debug, Clone)]
pub struct GossipVerifiedProposerPreferences {
pub signed_preferences: Arc<SignedProposerPreferences>,
}
impl GossipVerifiedProposerPreferences {
pub fn new<T: BeaconChainTypes>(
signed_preferences: Arc<SignedProposerPreferences>,
ctx: &GossipVerificationContext<'_, T>,
) -> Result<Self, ProposerPreferencesError> {
let proposal_slot = signed_preferences.message.proposal_slot;
let validator_index = signed_preferences.message.validator_index;
let cached_head = ctx.canonical_head.cached_head();
let current_slot = ctx
.slot_clock
.now()
.ok_or(ProposerPreferencesError::UnableToReadSlot)?;
let head_state = &cached_head.snapshot.beacon_state;
if ctx
.gossip_verified_proposer_preferences_cache
.get_seen_validator(&proposal_slot, validator_index)
{
return Err(ProposerPreferencesError::AlreadySeen {
validator_index,
proposal_slot,
});
}
verify_preferences_consistency(&signed_preferences.message, current_slot, head_state)?;
// Verify signature
proposer_preferences_signature_set(
head_state,
|i| get_pubkey_from_state(head_state, i),
&signed_preferences,
ctx.spec,
)
.map_err(|_| ProposerPreferencesError::BadSignature)?
.verify()
.then_some(())
.ok_or(ProposerPreferencesError::BadSignature)?;
let gossip_verified = GossipVerifiedProposerPreferences { signed_preferences };
ctx.gossip_verified_proposer_preferences_cache
.insert_seen_validator(&gossip_verified);
ctx.gossip_verified_proposer_preferences_cache
.insert_preferences(gossip_verified.clone());
Ok(gossip_verified)
}
}
impl<T: BeaconChainTypes> BeaconChain<T> {
pub fn proposer_preferences_gossip_verification_context(
&self,
) -> GossipVerificationContext<'_, T> {
GossipVerificationContext {
canonical_head: &self.canonical_head,
gossip_verified_proposer_preferences_cache: &self
.gossip_verified_proposer_preferences_cache,
slot_clock: &self.slot_clock,
spec: &self.spec,
}
}
pub fn verify_proposer_preferences_for_gossip(
&self,
signed_preferences: Arc<SignedProposerPreferences>,
) -> Result<GossipVerifiedProposerPreferences, ProposerPreferencesError> {
let proposal_slot = signed_preferences.message.proposal_slot;
let validator_index = signed_preferences.message.validator_index;
let ctx = self.proposer_preferences_gossip_verification_context();
match GossipVerifiedProposerPreferences::new(signed_preferences, &ctx) {
Ok(verified) => {
debug!(
%proposal_slot,
%validator_index,
"Successfully verified gossip proposer preferences"
);
Ok(verified)
}
Err(e) => {
debug!(
error = e.to_string(),
%proposal_slot,
%validator_index,
"Rejected gossip proposer preferences"
);
Err(e)
}
}
}
}
#[cfg(test)]
mod tests {
use types::{Address, BeaconState, EthSpec, MinimalEthSpec, ProposerPreferences, Slot};
use super::verify_preferences_consistency;
use crate::proposer_preferences_verification::ProposerPreferencesError;
type E = MinimalEthSpec;
fn make_preferences(proposal_slot: Slot, validator_index: u64) -> ProposerPreferences {
ProposerPreferences {
proposal_slot,
validator_index,
fee_recipient: Address::ZERO,
gas_limit: 30_000_000,
}
}
fn state() -> BeaconState<E> {
BeaconState::new(0, <_>::default(), &E::default_spec())
}
#[test]
fn test_invalid_epoch_too_old() {
let current_slot = Slot::new(2 * E::slots_per_epoch());
let prefs = make_preferences(Slot::new(3), 0);
let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state());
assert!(matches!(
result,
Err(ProposerPreferencesError::InvalidProposalEpoch { .. })
));
}
#[test]
fn test_invalid_epoch_too_far_ahead() {
let current_slot = Slot::new(E::slots_per_epoch());
let prefs = make_preferences(Slot::new(3 * E::slots_per_epoch() + 1), 0);
let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state());
assert!(matches!(
result,
Err(ProposerPreferencesError::InvalidProposalEpoch { .. })
));
}
#[test]
fn test_proposal_slot_already_passed() {
let current_slot = Slot::new(10);
let prefs = make_preferences(Slot::new(9), 0);
let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state());
assert!(matches!(
result,
Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. })
));
}
#[test]
fn test_proposal_slot_equal_to_current() {
let current_slot = Slot::new(10);
let prefs = make_preferences(Slot::new(10), 0);
let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state());
assert!(matches!(
result,
Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. })
));
}
}

View File

@@ -0,0 +1,70 @@
//! Gossip verification for proposer preferences.
//!
//! A `SignedProposerPreferences` is verified and wrapped as a `GossipVerifiedProposerPreferences`,
//! which is then inserted into the `GossipVerifiedProposerPreferenceCache`.
//!
//! ```ignore
//! SignedProposerPreferences
//! |
//! ▼
//! GossipVerifiedProposerPreferences -------> Insert into GossipVerifiedProposerPreferenceCache
//! ```
use std::sync::Arc;
use types::{BeaconStateError, Epoch, Slot};
use crate::BeaconChainError;
pub mod gossip_verified_proposer_preferences;
pub mod proposer_preference_cache;
#[cfg(test)]
mod tests;
#[derive(Debug)]
pub enum ProposerPreferencesError {
/// The proposal slot is not in the current or next epoch.
InvalidProposalEpoch { proposal_epoch: Epoch },
/// The proposal slot has already passed.
ProposalSlotAlreadyPassed {
proposal_slot: Slot,
current_slot: Slot,
},
/// The validator index does not match the proposer at the given slot.
InvalidProposalSlot {
validator_index: u64,
proposal_slot: Slot,
},
/// The slot clock cannot be read.
UnableToReadSlot,
/// A valid message from this validator for this slot has already been seen.
AlreadySeen {
validator_index: u64,
proposal_slot: Slot,
},
/// The signature is invalid.
BadSignature,
/// Some Beacon Chain Error
BeaconChainError(Arc<BeaconChainError>),
/// Some Beacon State error
BeaconStateError(BeaconStateError),
}
impl std::fmt::Display for ProposerPreferencesError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl From<BeaconStateError> for ProposerPreferencesError {
fn from(e: BeaconStateError) -> Self {
ProposerPreferencesError::BeaconStateError(e)
}
}
impl From<BeaconChainError> for ProposerPreferencesError {
fn from(e: BeaconChainError) -> Self {
ProposerPreferencesError::BeaconChainError(Arc::new(e))
}
}

View File

@@ -0,0 +1,107 @@
use std::{
collections::{BTreeMap, HashSet},
sync::Arc,
};
use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences;
use parking_lot::RwLock;
use types::{SignedProposerPreferences, Slot};
pub struct GossipVerifiedProposerPreferenceCache {
preferences: RwLock<BTreeMap<Slot, GossipVerifiedProposerPreferences>>,
seen: RwLock<BTreeMap<Slot, HashSet<u64>>>,
}
impl Default for GossipVerifiedProposerPreferenceCache {
fn default() -> Self {
Self {
preferences: RwLock::new(BTreeMap::new()),
seen: RwLock::new(BTreeMap::new()),
}
}
}
impl GossipVerifiedProposerPreferenceCache {
pub fn get_preferences(&self, slot: &Slot) -> Option<Arc<SignedProposerPreferences>> {
self.preferences
.read()
.get(slot)
.map(|p| p.signed_preferences.clone())
}
pub fn insert_preferences(&self, preferences: GossipVerifiedProposerPreferences) {
let slot = preferences.signed_preferences.message.proposal_slot;
self.preferences.write().insert(slot, preferences);
}
pub fn get_seen_validator(&self, slot: &Slot, validator_index: u64) -> bool {
self.seen
.read()
.get(slot)
.is_some_and(|seen| seen.contains(&validator_index))
}
pub fn insert_seen_validator(&self, preferences: &GossipVerifiedProposerPreferences) {
let slot = preferences.signed_preferences.message.proposal_slot;
let validator_index = preferences.signed_preferences.message.validator_index;
self.seen
.write()
.entry(slot)
.or_default()
.insert(validator_index);
}
pub fn prune(&self, current_slot: Slot) {
self.preferences
.write()
.retain(|&slot, _| slot >= current_slot);
self.seen.write().retain(|&slot, _| slot >= current_slot);
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use bls::Signature;
use types::{Address, ProposerPreferences, SignedProposerPreferences, Slot};
use super::GossipVerifiedProposerPreferenceCache;
use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences;
fn make_gossip_verified(slot: Slot, validator_index: u64) -> GossipVerifiedProposerPreferences {
GossipVerifiedProposerPreferences {
signed_preferences: Arc::new(SignedProposerPreferences {
message: ProposerPreferences {
proposal_slot: slot,
validator_index,
fee_recipient: Address::ZERO,
gas_limit: 30_000_000,
},
signature: Signature::empty(),
}),
}
}
#[test]
fn prune_removes_old_retains_current() {
let cache = GossipVerifiedProposerPreferenceCache::default();
for slot in [1, 2, 3, 7, 8, 9, 10] {
let verified = make_gossip_verified(Slot::new(slot), slot);
cache.insert_seen_validator(&verified);
cache.insert_preferences(verified);
}
cache.prune(Slot::new(8));
for slot in [1, 2, 3, 7] {
assert!(cache.get_preferences(&Slot::new(slot)).is_none());
assert!(!cache.get_seen_validator(&Slot::new(slot), slot));
}
for slot in [8, 9, 10] {
assert!(cache.get_preferences(&Slot::new(slot)).is_some());
assert!(cache.get_seen_validator(&Slot::new(slot), slot));
}
}
}

View File

@@ -0,0 +1,279 @@
use std::sync::Arc;
use std::time::Duration;
use bls::Signature;
use fork_choice::ForkChoice;
use genesis::{generate_deterministic_keypairs, interop_genesis_state};
use proto_array::PayloadStatus;
use slot_clock::{SlotClock, TestingSlotClock};
use store::{HotColdDB, StoreConfig};
use types::{
Address, BeaconBlock, ChainSpec, Checkpoint, Epoch, EthSpec, Hash256, MinimalEthSpec,
ProposerPreferences, SignedBeaconBlock, SignedProposerPreferences, Slot,
};
use crate::{
beacon_fork_choice_store::BeaconForkChoiceStore,
beacon_snapshot::BeaconSnapshot,
canonical_head::CanonicalHead,
proposer_preferences_verification::{
ProposerPreferencesError,
gossip_verified_proposer_preferences::{
GossipVerificationContext, GossipVerifiedProposerPreferences,
},
proposer_preference_cache::GossipVerifiedProposerPreferenceCache,
},
test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec},
};
type E = MinimalEthSpec;
type T = EphemeralHarnessType<E>;
const NUM_VALIDATORS: usize = 64;
struct TestContext {
canonical_head: CanonicalHead<T>,
preferences_cache: GossipVerifiedProposerPreferenceCache,
slot_clock: TestingSlotClock,
spec: ChainSpec,
}
impl TestContext {
fn new() -> Self {
let spec = test_spec::<E>();
let store = Arc::new(
HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone()))
.expect("should open ephemeral store"),
);
let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS);
let mut state =
interop_genesis_state::<E>(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec)
.expect("should build genesis state");
*state.finalized_checkpoint_mut() = Checkpoint {
epoch: Epoch::new(1),
root: Hash256::ZERO,
};
let mut genesis_block = BeaconBlock::empty(&spec);
*genesis_block.state_root_mut() = state
.update_tree_hash_cache()
.expect("should hash genesis state");
let signed_block = SignedBeaconBlock::from_block(genesis_block, Signature::empty());
let block_root = signed_block.canonical_root();
let snapshot = BeaconSnapshot::new(
Arc::new(signed_block.clone()),
None,
block_root,
state.clone(),
);
let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone())
.expect("should create fork choice store");
let fork_choice =
ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec)
.expect("should create fork choice");
let canonical_head =
CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending);
let slot_clock = TestingSlotClock::new(
Slot::new(0),
Duration::from_secs(0),
spec.get_slot_duration(),
);
Self {
canonical_head,
preferences_cache: GossipVerifiedProposerPreferenceCache::default(),
slot_clock,
spec,
}
}
fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> {
GossipVerificationContext {
canonical_head: &self.canonical_head,
gossip_verified_proposer_preferences_cache: &self.preferences_cache,
slot_clock: &self.slot_clock,
spec: &self.spec,
}
}
fn proposer_at_slot(&self, slot: Slot) -> u64 {
let head = self.canonical_head.cached_head();
let state = &head.snapshot.beacon_state;
let lookahead = state
.proposer_lookahead()
.expect("Gloas state has lookahead");
let slot_in_epoch = slot.as_usize() % E::slots_per_epoch() as usize;
let epoch = slot.epoch(E::slots_per_epoch());
let current_epoch = state.slot().epoch(E::slots_per_epoch());
let index = if epoch == current_epoch.saturating_add(1u64) {
E::slots_per_epoch() as usize + slot_in_epoch
} else {
slot_in_epoch
};
*lookahead.get(index).expect("index in range")
}
}
fn make_signed_preferences(
proposal_slot: Slot,
validator_index: u64,
) -> Arc<SignedProposerPreferences> {
Arc::new(SignedProposerPreferences {
message: ProposerPreferences {
proposal_slot,
validator_index,
fee_recipient: Address::ZERO,
gas_limit: 30_000_000,
},
signature: Signature::empty(),
})
}
#[test]
fn already_seen_validator() {
if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) {
return;
}
let ctx = TestContext::new();
let gossip = ctx.gossip_ctx();
let slot = Slot::new(1);
let verified = GossipVerifiedProposerPreferences {
signed_preferences: make_signed_preferences(slot, 42),
};
ctx.preferences_cache.insert_seen_validator(&verified);
let prefs = make_signed_preferences(slot, 42);
let result = GossipVerifiedProposerPreferences::new(prefs, &gossip);
assert!(matches!(
result,
Err(ProposerPreferencesError::AlreadySeen {
validator_index: 42,
..
})
));
}
#[test]
fn invalid_epoch_too_far_ahead() {
if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) {
return;
}
let ctx = TestContext::new();
let gossip = ctx.gossip_ctx();
let far_slot = Slot::new(3 * E::slots_per_epoch());
let prefs = make_signed_preferences(far_slot, 0);
let result = GossipVerifiedProposerPreferences::new(prefs, &gossip);
assert!(matches!(
result,
Err(ProposerPreferencesError::InvalidProposalEpoch { .. })
));
}
#[test]
fn proposal_slot_already_passed() {
if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) {
return;
}
let ctx = TestContext::new();
let gossip = ctx.gossip_ctx();
let prefs = make_signed_preferences(Slot::new(0), 0);
let result = GossipVerifiedProposerPreferences::new(prefs, &gossip);
assert!(matches!(
result,
Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. })
));
}
#[test]
fn wrong_proposer_for_slot() {
if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) {
return;
}
let ctx = TestContext::new();
let gossip = ctx.gossip_ctx();
let slot = Slot::new(1);
let actual_proposer = ctx.proposer_at_slot(slot);
let wrong_validator = if actual_proposer == 0 { 1 } else { 0 };
let prefs = make_signed_preferences(slot, wrong_validator);
let result = GossipVerifiedProposerPreferences::new(prefs, &gossip);
assert!(matches!(
result,
Err(ProposerPreferencesError::InvalidProposalSlot { .. })
));
}
#[test]
fn correct_proposer_bad_signature() {
if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) {
return;
}
let ctx = TestContext::new();
let gossip = ctx.gossip_ctx();
let slot = Slot::new(1);
let actual_proposer = ctx.proposer_at_slot(slot);
let prefs = make_signed_preferences(slot, actual_proposer);
let result = GossipVerifiedProposerPreferences::new(prefs, &gossip);
assert!(matches!(
result,
Err(ProposerPreferencesError::BadSignature)
));
assert!(
!ctx.preferences_cache
.get_seen_validator(&slot, actual_proposer)
);
assert!(ctx.preferences_cache.get_preferences(&slot).is_none());
}
#[test]
fn validator_index_out_of_bounds() {
if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) {
return;
}
let ctx = TestContext::new();
let gossip = ctx.gossip_ctx();
let slot = Slot::new(1);
let prefs = make_signed_preferences(slot, u64::MAX);
let result = GossipVerifiedProposerPreferences::new(prefs, &gossip);
assert!(matches!(
result,
Err(ProposerPreferencesError::InvalidProposalSlot { .. })
));
}
// TODO(gloas) add successful proposer preferences check once we have proposer preferences signing logic
#[test]
fn preferences_for_next_epoch_slot() {
if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) {
return;
}
let ctx = TestContext::new();
let gossip = ctx.gossip_ctx();
// Head is at slot 0 (epoch 0). Pick a slot in epoch 1.
let next_epoch_slot = Slot::new(E::slots_per_epoch() + 1);
let actual_proposer = ctx.proposer_at_slot(next_epoch_slot);
let prefs = make_signed_preferences(next_epoch_slot, actual_proposer);
let result = GossipVerifiedProposerPreferences::new(prefs, &gossip);
// Should pass consistency checks but fail on signature (empty sig).
assert!(
matches!(result, Err(ProposerPreferencesError::BadSignature)),
"expected BadSignature for next-epoch slot, got: {:?}",
result
);
}