diff --git a/crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs b/crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs index 260c7e79..6e3ee92b 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/aggregate.rs @@ -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, @@ -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, @@ -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. @@ -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( @@ -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; @@ -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() @@ -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() diff --git a/crates/blockchain/state_transition/src/beacon/gossip/attestation.rs b/crates/blockchain/state_transition/src/beacon/gossip/attestation.rs index 4db9cd8b..62ae437f 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/attestation.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/attestation.rs @@ -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, }; @@ -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); @@ -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, @@ -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 + ); + } } diff --git a/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs index dd35862c..723d59d0 100644 --- a/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs +++ b/crates/blockchain/state_transition/src/beacon/gossip/test_support.rs @@ -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, diff --git a/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs index 677e895e..fff9f1b5 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/accessors.rs @@ -411,11 +411,68 @@ pub fn get_domain(state: &BeaconState, domain_type: DomainType, epoch: Option 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); diff --git a/crates/blockchain/state_transition/src/beacon/helpers/attestation.rs b/crates/blockchain/state_transition/src/beacon/helpers/attestation.rs index e6945231..b225195c 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/attestation.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/attestation.rs @@ -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. @@ -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) { @@ -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) } diff --git a/crates/blockchain/state_transition/src/beacon/helpers/electra.rs b/crates/blockchain/state_transition/src/beacon/helpers/electra.rs index b2afcf7c..c4a4d22f 100644 --- a/crates/blockchain/state_transition/src/beacon/helpers/electra.rs +++ b/crates/blockchain/state_transition/src/beacon/helpers/electra.rs @@ -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::{ @@ -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) { @@ -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) } diff --git a/crates/net/rpc/src/beacon/pool.rs b/crates/net/rpc/src/beacon/pool.rs index 86809218..ad5eadf0 100644 --- a/crates/net/rpc/src/beacon/pool.rs +++ b/crates/net/rpc/src/beacon/pool.rs @@ -30,7 +30,7 @@ use ethlambda_state_transition::beacon::{ bls, gossip::attestation::compute_subnet_for_attestation, gossip::{Outcome, aggregate}, - helpers::accessors::{CommitteeCacheExt as _, get_domain}, + helpers::accessors::{CommitteeCacheExt as _, get_domain_from_schedule}, }; use ethlambda_storage::Store; use ethlambda_types::{ @@ -212,12 +212,14 @@ fn validate( let committee_len = committee.len(); // [REJECT] The signature is valid, under the attester domain at the target - // epoch. + // epoch. The schedule's domain, not the head state's: the head may still be + // the previous fork's while the target epoch's first slots are empty. let pubkey = state .validator(attestation.attester_index) .map_err(|_| "attester index is unknown")? .pubkey; - let domain = get_domain(state, DOMAIN_BEACON_ATTESTER, Some(data.target.epoch)); + let domain = + get_domain_from_schedule(&config, state, DOMAIN_BEACON_ATTESTER, data.target.epoch); let signing_root = compute_signing_root(data.hash_tree_root(), domain); if !bls::verify(&pubkey, signing_root, &attestation.signature) { return Err("invalid signature"); @@ -383,13 +385,14 @@ mod tests { use std::sync::Arc; use super::*; - use crate::test_utils::{RecordingNetwork, beacon_store_at}; + use crate::test_utils::{RecordingNetwork, beacon_store_at, beacon_store_with_config}; use axum::{body::Body, http::Request}; use ethlambda_state_transition::beacon::helpers::{ - accessors::get_beacon_committee, + accessors::{get_beacon_committee, get_domain_from_schedule}, test_state::{sign_for, with_signing_validators_at}, }; - use ethlambda_types::beacon::containers::shared::{AttestationData, Checkpoint}; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::containers::shared::{AttestationData, Checkpoint, Fork}; use http_body_util::BodyExt as _; use tower::ServiceExt as _; @@ -423,10 +426,46 @@ mod tests { } } + /// An electra head whose epoch is the last before a fulu fork the + /// schedule places at the wall clock's epoch: what a node holds while the + /// new fork's first slot has no block yet. The head sits at its epoch's + /// first slot, as [`fixture`]'s does, so it is its own checkpoint. + fn fork_boundary_fixture() -> Fixture { + let mut state = with_signing_validators_at(ForkName::Electra, 64); + let (probe, _) = beacon_store_at(state.clone()); + let wall_epoch = compute_epoch_at_slot(crate::beacon::node::wall_slot(&probe)); + let config = Config::mainnet().with_fork_epoch(ForkName::Fulu, wall_epoch); + *state.slot_mut() = compute_start_slot_at_epoch(wall_epoch - 1); + *state.fork_mut() = Fork { + previous_version: config.deneb_fork_version, + current_version: config.electra_fork_version, + epoch: config.electra_fork_epoch, + }; + let (store, head_root) = beacon_store_with_config(state.clone(), config); + Fixture { + store, + state, + head_root, + network: Arc::new(RecordingNetwork::default()), + pool: SharedAttestationPool::default(), + } + } + /// A correctly signed attestation from `committee`'s member at `position`, /// voting for the head at the head's own slot. fn attestation(fixture: &Fixture, committee_index: u64, position: usize) -> SingleAttestation { - let slot = fixture.state.slot(); + attestation_at(fixture, fixture.state.slot(), committee_index, position) + } + + /// [`attestation`] at `slot`, still voting for the head, signed the way a + /// validator client does: under the domain its fork schedule names for + /// the target epoch. + fn attestation_at( + fixture: &Fixture, + slot: Slot, + committee_index: u64, + position: usize, + ) -> SingleAttestation { let epoch = compute_epoch_at_slot(slot); let data = AttestationData { slot, @@ -440,7 +479,12 @@ mod tests { }; let committee = get_beacon_committee(&fixture.state, slot, committee_index).unwrap(); let attester_index = committee[position]; - let domain = get_domain(&fixture.state, DOMAIN_BEACON_ATTESTER, Some(epoch)); + let domain = get_domain_from_schedule( + &fixture.store.config(), + &fixture.state, + DOMAIN_BEACON_ATTESTER, + epoch, + ); let signing_root = compute_signing_root(data.hash_tree_root(), domain); SingleAttestation { committee_index, @@ -600,10 +644,11 @@ mod tests { .collect(); let signature: ethlambda_types::beacon::primitives::BlsSignature = serde_json::from_value(json["data"]["signature"].clone()).unwrap(); - let domain = get_domain( + let domain = get_domain_from_schedule( + &fixture.store.config(), &fixture.state, DOMAIN_BEACON_ATTESTER, - Some(data.target.epoch), + data.target.epoch, ); let signing_root = compute_signing_root(data.hash_tree_root(), domain); assert!( @@ -635,7 +680,9 @@ mod tests { }; let slot = aggregate.data.slot; let epoch = compute_epoch_at_slot(slot); - let selection_domain = get_domain(&fixture.state, DOMAIN_SELECTION_PROOF, Some(epoch)); + let config = fixture.store.config(); + let selection_domain = + get_domain_from_schedule(&config, &fixture.state, DOMAIN_SELECTION_PROOF, epoch); let selection_proof = sign_for( aggregator as usize, compute_signing_root(slot.hash_tree_root(), selection_domain), @@ -645,7 +692,8 @@ mod tests { aggregate, selection_proof, }; - let domain = get_domain(&fixture.state, DOMAIN_AGGREGATE_AND_PROOF, Some(epoch)); + let domain = + get_domain_from_schedule(&config, &fixture.state, DOMAIN_AGGREGATE_AND_PROOF, epoch); let signature = sign_for( aggregator as usize, compute_signing_root(message.hash_tree_root(), domain), @@ -728,6 +776,52 @@ mod tests { assert!(fixture.network.aggregates.lock().unwrap().is_empty()); } + /// The first slot of the epoch after [`fork_boundary_fixture`]'s head. + fn first_slot_of_the_fork(fixture: &Fixture) -> Slot { + compute_start_slot_at_epoch(compute_epoch_at_slot(fixture.state.slot()) + 1) + } + + /// A vote in a fork's first slot, before any block of that fork: checked + /// against the previous fork's head state, signed under the new fork. + #[tokio::test] + async fn an_attestation_in_a_forks_first_empty_slot_is_published() { + let fixture = fork_boundary_fixture(); + let slot = first_slot_of_the_fork(&fixture); + let attestation = attestation_at(&fixture, slot, 0, 0); + let (status, json) = submit(&fixture, std::slice::from_ref(&attestation)).await; + assert_eq!(status, StatusCode::OK, "{json}"); + let published = fixture.network.published.lock().unwrap(); + assert_eq!(published.len(), 1); + assert_eq!(published[0].1, attestation); + } + + /// The aggregate counterpart: its selection proof, its aggregator's + /// signature and the aggregate's own signature are all the new fork's. + #[tokio::test] + async fn an_aggregate_in_a_forks_first_empty_slot_is_published() { + let fixture = fork_boundary_fixture(); + let slot = first_slot_of_the_fork(&fixture); + let committee = get_beacon_committee(&fixture.state, slot, 0).unwrap(); + let votes: Vec = (0..committee.len()) + .map(|position| attestation_at(&fixture, slot, 0, position)) + .collect(); + let (status, json) = submit(&fixture, &votes).await; + assert_eq!(status, StatusCode::OK, "{json}"); + let aggregate = fixture + .pool + .lock() + .unwrap() + .aggregate(votes[0].data.hash_tree_root(), slot, 0) + .unwrap(); + + // 64 validators give each committee fewer members than + // TARGET_AGGREGATORS_PER_COMMITTEE, so every member is an aggregator. + let signed = signed_aggregate(&fixture, committee[0], aggregate); + let (status, json) = submit_aggregates(&fixture, std::slice::from_ref(&signed)).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(fixture.network.aggregates.lock().unwrap().len(), 1); + } + #[tokio::test] async fn a_pre_electra_fork_header_is_refused() { let fixture = fixture(); diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index 16663d75..93ee7408 100644 --- a/crates/net/rpc/src/beacon/validator_client_tests.rs +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -14,7 +14,7 @@ use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus}; use ethlambda_network_api::RpcToP2PRef; use ethlambda_state_transition::beacon::attestation_pool::SharedAttestationPool; use ethlambda_state_transition::beacon::helpers::{ - accessors::get_domain, + accessors::{get_domain, get_domain_from_schedule}, fulu::initialize_proposer_lookahead, test_state::{sign_for, with_signing_validators_at}, }; @@ -158,7 +158,7 @@ async fn the_validator_client_can_attest_through_this_node() { // The attestation data is checked by the client itself before it signs. let data = client.attestation_data(slot).await.unwrap(); - let domain = get_domain(&state, DOMAIN_BEACON_ATTESTER, Some(data.target.epoch)); + let domain = scheduled_domain(&state, DOMAIN_BEACON_ATTESTER, data.target.epoch); let signing_root = compute_signing_root(data.hash_tree_root(), domain); let data_dto = AttestationDataOutDto::from(&data); let attestations: Vec = attesters @@ -224,7 +224,7 @@ fn signed_aggregate( }; let slot = aggregate.data.slot; let epoch = compute_epoch_at_slot(slot); - let selection_domain = get_domain(state, DOMAIN_SELECTION_PROOF, Some(epoch)); + let selection_domain = scheduled_domain(state, DOMAIN_SELECTION_PROOF, epoch); let selection_proof = sign_for( aggregator as usize, compute_signing_root(slot.hash_tree_root(), selection_domain), @@ -234,7 +234,7 @@ fn signed_aggregate( aggregate, selection_proof, }; - let domain = get_domain(state, DOMAIN_AGGREGATE_AND_PROOF, Some(epoch)); + let domain = scheduled_domain(state, DOMAIN_AGGREGATE_AND_PROOF, epoch); let signature = sign_for( aggregator as usize, compute_signing_root(message.hash_tree_root(), domain), @@ -242,6 +242,18 @@ fn signed_aggregate( SignedAggregateAndProof { message, signature } } +/// The domain a validator client signs `domain_type` under at `epoch`: its +/// fork schedule's, which is the node's (`beacon_store_at` serves mainnet's), +/// rather than the test state's placeholder `fork`. +fn scheduled_domain( + state: &BeaconState, + domain_type: ethlambda_types::beacon::primitives::DomainType, + epoch: ethlambda_types::beacon::primitives::Epoch, +) -> ethlambda_types::beacon::primitives::Domain { + let config = ethlambda_types::beacon::config::Config::mainnet(); + get_domain_from_schedule(&config, state, domain_type, epoch) +} + /// Without an execution client to build a payload with, block production /// answers 503, which the client reads as a node it can fail over from rather /// than a malformed answer. diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index a2144f7c..3b8323d0 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -463,13 +463,18 @@ pub(crate) mod test_utils { /// phase0 one whatever `state`'s fork: these endpoints read the state and /// the block's root and slot, never the block's body. pub(crate) fn beacon_store_at(state: BeaconState) -> (Store, H256) { + beacon_store_with_config(state, Config::mainnet()) + } + + /// [`beacon_store_at`] under a fork schedule of the test's own. + pub(crate) fn beacon_store_with_config(state: BeaconState, config: Config) -> (Store, H256) { let slot = state.slot(); let block = phase0_beacon_block(slot, H256::ZERO); let root = block.message_hash_tree_root(); let mut store = Store::init_beacon( Arc::new(InMemoryBackend::default()), 1_606_824_023, - Config::mainnet(), + config, root, Checkpoint { root, slot }, slot,