Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,13 @@ use super::{
};
use crate::beacon::bls;
use crate::beacon::constants::{
DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_SELECTION_PROOF, TARGET_AGGREGATORS_PER_COMMITTEE,
DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_BEACON_ATTESTER, DOMAIN_SELECTION_PROOF,
TARGET_AGGREGATORS_PER_COMMITTEE,
};
use crate::beacon::containers::SignedAggregateAndProof;
use crate::beacon::fork_choice::Store;
use crate::beacon::hash::hash;
use crate::beacon::helpers::accessors::{CommitteeCacheExt, get_domain};
use crate::beacon::helpers::accessors::{CommitteeCacheExt, get_domain_from_schedule};
use crate::beacon::helpers::math::bytes_to_uint64;
use crate::beacon::helpers::misc::{
compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch,
Expand Down Expand Up @@ -319,6 +320,11 @@ pub fn cheap_checks(
/// ([`super::ancestor_at`]) without a `Store::block_index` / `LiveChain`
/// scan, because a block's post-state always has history back through its
/// own ancestors.
///
/// What this state cannot answer is the signing domain: when the target
/// epoch's first slots are empty, the vote block's state still carries the
/// previous fork, so all three signatures are checked under the schedule's
/// domain for the target epoch instead (see [`get_domain_from_schedule`]).
pub fn stateful_checks(
store: &Store,
aggregate: &SignedAggregateAndProof,
Expand All @@ -338,6 +344,7 @@ pub fn stateful_checks(
};

let target_epoch = data.target.epoch;
let config = store.config();

// Pubkey-only signatures, before any committee derivation; see the
// module documentation for why this order.
Expand All @@ -347,13 +354,15 @@ pub fn stateful_checks(
};
// [REJECT] The selection proof selects the validator as an aggregator.
let selection_proof = aggregate.selection_proof();
let selection_domain = get_domain(&state, DOMAIN_SELECTION_PROOF, Some(target_epoch));
let selection_domain =
get_domain_from_schedule(&config, &state, DOMAIN_SELECTION_PROOF, target_epoch);
let selection_signing_root = compute_signing_root(data.slot.hash_tree_root(), selection_domain);
if !bls::verify(&aggregator.pubkey, selection_signing_root, &selection_proof) {
return Err(Outcome::Reject(RejectReason::SelectionProof));
}
// [REJECT] The aggregator's own signature, over the whole envelope.
let aggregator_domain = get_domain(&state, DOMAIN_AGGREGATE_AND_PROOF, Some(target_epoch));
let aggregator_domain =
get_domain_from_schedule(&config, &state, DOMAIN_AGGREGATE_AND_PROOF, target_epoch);
let aggregator_signing_root =
compute_signing_root(aggregate_and_proof_root(aggregate), aggregator_domain);
if !bls::verify(
Expand Down Expand Up @@ -398,6 +407,8 @@ pub fn stateful_checks(

// [REJECT] The aggregate's own signature is valid. Built from the same
// (cached) committees, so this costs no further shuffle.
let attester_domain =
get_domain_from_schedule(&config, &state, DOMAIN_BEACON_ATTESTER, target_epoch);
let attesting_indices = match aggregate {
SignedAggregateAndProof::Phase0(signed) => {
let phase0_attestation = &signed.message.aggregate;
Expand All @@ -407,8 +418,11 @@ pub fn stateful_checks(
&committees,
)
.map_err(|_| Outcome::Ignore(IgnoreReason::Internal))?;
if !crate::beacon::helpers::attestation::is_valid_indexed_attestation(&state, &indexed)
{
if !crate::beacon::helpers::attestation::is_valid_indexed_attestation_with_domain(
&state,
&indexed,
attester_domain,
) {
return Err(Outcome::Reject(RejectReason::AggregateSignature));
}
indexed.attesting_indices.to_vec()
Expand All @@ -421,7 +435,11 @@ pub fn stateful_checks(
&committees,
)
.map_err(|_| Outcome::Ignore(IgnoreReason::Internal))?;
if !crate::beacon::helpers::electra::is_valid_indexed_attestation(&state, &indexed) {
if !crate::beacon::helpers::electra::is_valid_indexed_attestation_with_domain(
&state,
&indexed,
attester_domain,
) {
return Err(Outcome::Reject(RejectReason::AggregateSignature));
}
indexed.attesting_indices.to_vec()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use crate::beacon::constants::DOMAIN_BEACON_ATTESTER;
use crate::beacon::containers::electra::SingleAttestation;
use crate::beacon::fork_choice::Store;
use crate::beacon::helpers::accessors::CommitteeCacheExt;
use crate::beacon::helpers::accessors::get_domain;
use crate::beacon::helpers::accessors::get_domain_from_schedule;
use crate::beacon::helpers::misc::{
compute_epoch_at_slot, compute_signing_root, compute_start_slot_at_epoch,
};
Expand Down Expand Up @@ -172,12 +172,14 @@ pub fn stateful_checks(store: &Store, attestation: &SingleAttestation, subnet_id
};

let target_epoch = data.target.epoch;
let config = store.config();

// The pubkey-only signature, before any committee derivation.
// The pubkey-only signature, before any committee derivation, under the
// schedule's domain: the voted block's state may predate the target's fork.
let Ok(attester) = state.validator(attestation.attester_index) else {
return Outcome::Reject(RejectReason::UnknownValidator);
};
let domain = get_domain(&state, DOMAIN_BEACON_ATTESTER, Some(target_epoch));
let domain = get_domain_from_schedule(&config, &state, DOMAIN_BEACON_ATTESTER, target_epoch);
let signing_root = compute_signing_root(data.hash_tree_root(), domain);
if !bls::verify(&attester.pubkey, signing_root, &attestation.signature) {
return Outcome::Reject(RejectReason::BadSignature);
Expand All @@ -191,7 +193,6 @@ pub fn stateful_checks(store: &Store, attestation: &SingleAttestation, subnet_id
return Outcome::Reject(RejectReason::CommitteeIndex);
}
// [New in Electra:EIP7549] [REJECT] The correct subnet.
let config = store.config();
let expected_subnet = compute_subnet_for_attestation(
epoch_committees.committees_per_slot(),
data.slot,
Expand Down Expand Up @@ -416,4 +417,90 @@ mod tests {
Outcome::Ignore(IgnoreReason::UnknownBlock)
);
}

/// A vote cast in a fork's first slot while that slot has no block: the
/// voted block, and so the state it is checked against, is still the
/// previous fork's, but the vote is signed under the new fork's version.
#[test]
fn a_vote_across_a_fork_boundary_verifies_under_the_new_forks_version() {
use crate::beacon::containers::shared::{AttestationData, Checkpoint, Fork};
use crate::beacon::containers::{SignedBeaconBlock, electra};
use crate::beacon::fork::ForkName;
use crate::beacon::gossip::test_support::store_with_config;
use crate::beacon::helpers::accessors::get_beacon_committee;
use crate::beacon::helpers::misc::compute_domain;
use crate::beacon::helpers::test_state::{sign_for, with_signing_validators_at};

let fulu_epoch = 2;
let config = Config::mainnet()
.with_fork_epoch(ForkName::Electra, 0)
.with_fork_epoch(ForkName::Fulu, fulu_epoch);
let mut state = with_signing_validators_at(ForkName::Electra, 64);
let pre_fork_slot = compute_start_slot_at_epoch(fulu_epoch) - 1;
*state.slot_mut() = pre_fork_slot;
*state.fork_mut() = Fork {
previous_version: config.deneb_fork_version,
current_version: config.electra_fork_version,
epoch: 0,
};
state.apply_pending_mutations();

let mut store = store_with_config(0, config.clone());
let block_root = Root::repeat_byte(7);
let block = SignedBeaconBlock::Electra(electra::SignedBeaconBlock {
message: electra::BeaconBlock {
slot: pre_fork_slot,
proposer_index: 0,
parent_root: Root::ZERO,
state_root: Root::ZERO,
body: electra::BeaconBlockBody::empty(),
},
signature: Default::default(),
});
store
.insert_pending_block(block_root, block)
.expect("insert the voted block");
store.cache_state(
CacheKey::BlockState(block_root),
std::sync::Arc::new(state.clone()),
);

let slot = compute_start_slot_at_epoch(fulu_epoch);
let committee = get_beacon_committee(&state, slot, 0).expect("committee");
let attester_index = committee[0];
let data = AttestationData {
slot,
index: 0,
beacon_block_root: block_root,
source: Default::default(),
target: Checkpoint {
epoch: fulu_epoch,
root: block_root,
},
};
let domain = compute_domain(
DOMAIN_BEACON_ATTESTER,
config.fulu_fork_version,
state.genesis_validators_root(),
);
let attestation = SingleAttestation {
committee_index: 0,
attester_index,
signature: sign_for(
attester_index as usize,
compute_signing_root(data.hash_tree_root(), domain),
),
data,
};
let committees_per_slot = store
.committee_cache()
.committees(&state, fulu_epoch)
.committees_per_slot();
let subnet_id = compute_subnet_for_attestation(committees_per_slot, slot, 0, &config);

assert_eq!(
stateful_checks(&store, &attestation, subnet_id),
Outcome::Accept
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,18 @@ pub(crate) const GENESIS_TIME: u64 = 1_000;

/// A store with no blocks, finalized at `finalized_slot`, fulu from genesis.
pub(crate) fn store(finalized_slot: Slot) -> Store {
store_with_config(
finalized_slot,
Config::mainnet().with_fork_epoch(ForkName::Fulu, 0),
)
}

/// [`store`] under a fork schedule of the test's own.
pub(crate) fn store_with_config(finalized_slot: Slot, config: Config) -> Store {
Store::init_beacon(
Arc::new(InMemoryBackend::new()),
GENESIS_TIME,
Config::mainnet().with_fork_epoch(ForkName::Fulu, 0),
config,
Root::ZERO,
Checkpoint {
root: Root::ZERO,
Expand Down
57 changes: 57 additions & 0 deletions crates/blockchain/state_transition/src/beacon/helpers/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,11 +411,68 @@ pub fn get_domain(state: &BeaconState, domain_type: DomainType, epoch: Option<Ep
compute_domain(domain_type, fork_version, state.genesis_validators_root())
}

/// [`get_domain`] with the fork version read from `config`'s schedule at
/// `epoch` rather than from `state.fork`.
///
/// For signatures checked against a state that has not been advanced to the
/// message's epoch, which is what gossip and the Beacon API do: they read the
/// voted block's post-state, or the head's. When a fork's first slots are
/// empty, that state still carries the previous fork, so [`get_domain`] would
/// answer with the previous fork's version and reject every correctly signed
/// vote of the new fork until one of its blocks is imported. The state
/// transition keeps [`get_domain`]: it advances the state to the slot first,
/// and there the two agree.
pub fn get_domain_from_schedule(
config: &Config,
state: &BeaconState,
domain_type: DomainType,
epoch: Epoch,
) -> Domain {
let fork_version = config.fork_version(config.fork_at_epoch(epoch));
compute_domain(domain_type, fork_version, state.genesis_validators_root())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::beacon::containers::shared::Fork;
use crate::beacon::helpers::test_state::with_validators;

/// An electra state one epoch before a fulu fork: the two readings agree
/// on electra's own epochs and part at fulu's first.
#[test]
fn the_schedule_names_the_new_fork_before_the_state_reaches_it() {
let fulu_epoch = 10;
let config = Config::mainnet()
.with_fork_epoch(ForkName::Electra, 0)
.with_fork_epoch(ForkName::Fulu, fulu_epoch);
let mut state =
crate::beacon::helpers::test_state::with_validators_at(ForkName::Electra, 4);
*state.slot_mut() = compute_start_slot_at_epoch(fulu_epoch) - 1;
*state.fork_mut() = Fork {
previous_version: config.deneb_fork_version,
current_version: config.electra_fork_version,
epoch: 0,
};
let domain = constants::DOMAIN_BEACON_ATTESTER;

let before = fulu_epoch - 1;
assert_eq!(
get_domain_from_schedule(&config, &state, domain, before),
get_domain(&state, domain, Some(before))
);
let expected = compute_domain(
domain,
config.fulu_fork_version,
state.genesis_validators_root(),
);
assert_eq!(
get_domain_from_schedule(&config, &state, domain, fulu_epoch),
expected
);
assert_ne!(get_domain(&state, domain, Some(fulu_epoch)), expected);
}

#[test]
fn previous_epoch_is_clamped_at_genesis() {
let mut state = crate::beacon::helpers::test_state::with_validators(4);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::beacon::error::Result;
use crate::beacon::helpers::accessors::{CommitteeCache, CommitteeCacheExt, get_domain};
use crate::beacon::helpers::misc::{compute_epoch_at_slot, compute_signing_root};
use crate::beacon::helpers::predicates::are_indices_sorted_and_unique;
use crate::beacon::primitives::{HashTreeRoot as _, ValidatorIndex};
use crate::beacon::primitives::{Domain, HashTreeRoot as _, ValidatorIndex};
use crate::beacon::{bls, constants};

/// The committee members whose bit is set in `attestation`, in ascending order.
Expand Down Expand Up @@ -78,6 +78,24 @@ pub fn get_indexed_attestation(
pub fn is_valid_indexed_attestation(
state: &BeaconState,
indexed_attestation: &IndexedAttestation,
) -> bool {
let domain = get_domain(
state,
constants::DOMAIN_BEACON_ATTESTER,
Some(indexed_attestation.data.target.epoch),
);
is_valid_indexed_attestation_with_domain(state, indexed_attestation, domain)
}

/// [`is_valid_indexed_attestation`] under a signing domain the caller chose.
///
/// For gossip, which checks against a state that may not have reached the
/// attestation's fork: see
/// [`crate::beacon::helpers::accessors::get_domain_from_schedule`].
pub fn is_valid_indexed_attestation_with_domain(
state: &BeaconState,
indexed_attestation: &IndexedAttestation,
domain: Domain,
) -> bool {
let indices: &[ValidatorIndex] = &indexed_attestation.attesting_indices;
if indices.is_empty() || !are_indices_sorted_and_unique(indices) {
Expand All @@ -92,11 +110,6 @@ pub fn is_valid_indexed_attestation(
}
}

let domain = get_domain(
state,
constants::DOMAIN_BEACON_ATTESTER,
Some(indexed_attestation.data.target.epoch),
);
let signing_root = compute_signing_root(indexed_attestation.data.hash_tree_root(), domain);
bls::fast_aggregate_verify(&pubkeys, signing_root, &indexed_attestation.signature)
}
26 changes: 19 additions & 7 deletions crates/blockchain/state_transition/src/beacon/helpers/electra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ use crate::beacon::error::{Error, Result};
use crate::beacon::hash::hash;
use crate::beacon::preset;
use crate::beacon::primitives::{
BLS_SIGNATURE_SIZE, BlsSignature, Bytes32, CommitteeIndex, Epoch, Gwei, HashTreeRoot as _,
ValidatorIndex,
BLS_SIGNATURE_SIZE, BlsSignature, Bytes32, CommitteeIndex, Domain, Epoch, Gwei,
HashTreeRoot as _, ValidatorIndex,
};

use super::accessors::{
Expand Down Expand Up @@ -275,6 +275,23 @@ pub fn compute_proposer_index(
pub fn is_valid_indexed_attestation(
state: &BeaconState,
indexed_attestation: &electra::IndexedAttestation,
) -> bool {
let domain = get_domain(
state,
constants::DOMAIN_BEACON_ATTESTER,
Some(indexed_attestation.data.target.epoch),
);
is_valid_indexed_attestation_with_domain(state, indexed_attestation, domain)
}

/// [`is_valid_indexed_attestation`] under a signing domain the caller chose.
///
/// For gossip, which checks against a state that may not have reached the
/// attestation's fork: see [`super::accessors::get_domain_from_schedule`].
pub fn is_valid_indexed_attestation_with_domain(
state: &BeaconState,
indexed_attestation: &electra::IndexedAttestation,
domain: Domain,
) -> bool {
let indices: &[ValidatorIndex] = &indexed_attestation.attesting_indices;
if indices.is_empty() || !are_indices_sorted_and_unique(indices) {
Expand All @@ -289,11 +306,6 @@ pub fn is_valid_indexed_attestation(
}
}

let domain = get_domain(
state,
constants::DOMAIN_BEACON_ATTESTER,
Some(indexed_attestation.data.target.epoch),
);
let signing_root = compute_signing_root(indexed_attestation.data.hash_tree_root(), domain);
bls::fast_aggregate_verify(&pubkeys, signing_root, &indexed_attestation.signature)
}
Expand Down
Loading
Loading