From 7e06b3bbff72ab2479ee30f75c019fab88b12eeb Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 12 Aug 2026 15:15:39 +0100 Subject: [PATCH 01/13] Return verified artifacts from single-attestation verification verify_single_attestation now yields the AttestationData root and the parsed, subgroup-checked signature instead of a bool, so the upcoming aggregation pool can reuse them without re-hashing or re-parsing. No behavior change. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/bls.rs | 22 ++++++++++++++++----- crates/beacon_state/tile/src/tile/gossip.rs | 7 ++++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/beacon_state/tile/src/bls.rs b/crates/beacon_state/tile/src/bls.rs index 451afd31..ee9448e7 100644 --- a/crates/beacon_state/tile/src/bls.rs +++ b/crates/beacon_state/tile/src/bls.rs @@ -150,6 +150,10 @@ pub fn fork_version_at_epoch( /// available (e.g. `verify_deposit_signature`), use `verify_one_compressed`. pub(crate) fn verify_one(pk: &PublicKey, sig: &[u8; 96], message: &B256) -> bool { let Ok(sig) = Signature::from_bytes(sig) else { return false }; + verify_one_parsed(pk, &sig, message) +} + +fn verify_one_parsed(pk: &PublicKey, sig: &Signature, message: &B256) -> bool { sig.verify(true, message, DST, &[], pk, false) == BLST_ERROR::BLST_SUCCESS } @@ -412,6 +416,13 @@ pub fn verify_deposit_signature(pubkey: &BLSPubkey, sig: &[u8; 96], signing_root verify_one_compressed(pubkey, sig, signing_root) } +/// Signature is subgroup-checked by the verify, so downstream aggregation +/// may add it without a second group check. +pub struct VerifiedSingleAttestation { + pub data_root: B256, + pub signature: Signature, +} + /// Verify a single-attester `SingleAttestation` (gossip subnet form). Used /// on the gossip hot path; the body-included aggregate path goes through /// `stf::validate_attestations` + `SigBatch`. @@ -420,15 +431,16 @@ pub fn verify_single_attestation( attester_pubkey: &PublicKey, fork_version: [u8; 4], genesis_validators_root: &B256, -) -> bool { +) -> Option { let data = SingleAttestationView::data(att); - let sig = SingleAttestationView::signature(att); + let signature = Signature::from_bytes(SingleAttestationView::signature(att)).ok()?; - let object_root = hash_attestation_data(data.as_bytes()); + let data_root = hash_attestation_data(data.as_bytes()); let domain = compute_domain(DOMAIN_BEACON_ATTESTER, fork_version, genesis_validators_root); - let signing_root = compute_signing_root(&object_root, &domain); + let signing_root = compute_signing_root(&data_root, &domain); - verify_one(attester_pubkey, sig, &signing_root) + verify_one_parsed(attester_pubkey, &signature, &signing_root) + .then_some(VerifiedSingleAttestation { data_root, signature }) } #[cfg(test)] diff --git a/crates/beacon_state/tile/src/tile/gossip.rs b/crates/beacon_state/tile/src/tile/gossip.rs index af0e476e..e9fefc2f 100644 --- a/crates/beacon_state/tile/src/tile/gossip.rs +++ b/crates/beacon_state/tile/src/tile/gossip.rs @@ -84,13 +84,14 @@ impl BeaconStateTile { return Feedback::Reject(None); } let fork_version = view.epoch.fork_version_at(target_epoch); - let ok = bls::verify_single_attestation( + if bls::verify_single_attestation( buf, view.validators.pubkey_decompressed(attester_index), fork_version, &view.imm.genesis_validators_root, - ); - if !ok { + ) + .is_none() + { return Feedback::Reject(None); } From 89978e49579bac0a7812fdec1481c07724c3b7c3 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 12 Aug 2026 15:28:13 +0100 Subject: [PATCH 02/13] Enforce attestation subnet and per-epoch first-seen admission Thread the subnet id from the gossip topic into handle_attestation and reject attestations arriving on the wrong subnet. Track (target_epoch, attester_index) in a two-lane epoch-parity bitset so a validator's second attestation for the same target epoch is ignored before any fork-choice or BLS work; marking happens only after full validation. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/tile.rs | 117 ++++++++++++++++++-- crates/beacon_state/tile/src/tile/gossip.rs | 79 ++++++++++++- crates/common/src/lib.rs | 7 +- 3 files changed, 188 insertions(+), 15 deletions(-) diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 607d5430..2af1eda6 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -22,7 +22,7 @@ use crate::{ bls, fork_choice::{FORK_CHOICE_NODES_HINT, ForkChoice, PayloadStatus}, merkle, ssz_hash, stf, - tile::{orphan_pool::PendingBlock, shuffling_cache::ShufflingCache}, + tile::{gossip::SeenAttesters, orphan_pool::PendingBlock, shuffling_cache::ShufflingCache}, weak_subjectivity::{weak_subjectivity_period_fulu, weak_subjectivity_period_gloas}, }; @@ -115,6 +115,7 @@ pub struct BeaconStateTile { fork_choice: ForkChoice, shuffling_cache: Box, + seen_attesters: SeenAttesters, /// Highest finalized slot PM has announced as a sync target — bounds the /// data-availability requirement while range sync back-fills. @@ -213,6 +214,7 @@ impl BeaconStateTile { state: owner, fork_choice: ForkChoice::default(), shuffling_cache: ShufflingCache::with_capacity(val_cap), + seen_attesters: SeenAttesters::new(val_cap), last_applied: anchor, last_applied_block_root: [0u8; 32], initial_status_emitted: false, @@ -1252,7 +1254,7 @@ mod tests { let mut tile = make_tile(); seed_tile(&mut tile, 4, 10); let buf = [0u8; 100]; - tile.handle_attestation(&buf); + tile.handle_attestation(&buf, 0); assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, 0); } @@ -1479,6 +1481,18 @@ mod tests { panic!("validator 0 in some committee") } + /// Spec `compute_subnet_for_attestation`, recomputed independently of the + /// production helper. + fn expected_subnet(tile: &BeaconStateTile, slot: Slot, ci: usize) -> u64 { + let shuffled = tile + .shuffling_cache + .shuffled_by_epoch(slot / SLOTS_PER_EPOCH) + .expect("shuffling for epoch"); + let cps = + stf::EpochShuffling::new(shuffled, tile.head_validator_count()).committees_per_slot; + (cps as u64 * (slot % SLOTS_PER_EPOCH) + ci as u64) % 64 + } + fn build_agg_for_vi0(tile: &BeaconStateTile) -> Vec { let imm = seed_immutable(tile); let beacon_block_root = tile.last_applied_block_root; @@ -1503,6 +1517,7 @@ mod tests { let mut tile = make_tile_at_wall_slot(31); seed_tile_with_keys(&mut tile, 128, 0); let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); let imm = seed_immutable(&tile); // Vote for the (known) anchor block; target is the anchor's checkpoint // block, so the spec target/ancestor checks accept. @@ -1521,7 +1536,7 @@ mod tests { // the offsets), verifying the vote fold self-consistently. let want_root = *SingleAttestationView::beacon_block_root(&buf); let want_epoch = SingleAttestationView::target_epoch(&buf); - assert_eq!(tile.handle_attestation(&buf), Feedback::Accept(None)); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, want_root); assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, want_epoch); } @@ -1534,6 +1549,7 @@ mod tests { let mut tile = make_tile_at_wall_slot(31); seed_tile_with_keys(&mut tile, 128, 0); let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); let imm = seed_immutable(&tile); let unknown = [0xAAu8; 32]; let buf = test_signing::sign_single_attestation( @@ -1546,7 +1562,7 @@ mod tests { unknown, &imm, ); - assert_eq!(tile.handle_attestation(&buf), Feedback::Ignore); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); } /// Spec `validate_on_attestation`: a known-block vote whose target does not @@ -1556,6 +1572,7 @@ mod tests { let mut tile = make_tile_at_wall_slot(31); seed_tile_with_keys(&mut tile, 128, 0); let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); let imm = seed_immutable(&tile); let bbr = tile.last_applied_block_root; // known anchor let wrong_target = [0x77u8; 32]; @@ -1569,7 +1586,7 @@ mod tests { wrong_target, &imm, ); - assert_eq!(tile.handle_attestation(&buf), Feedback::Reject(None)); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Reject(None)); } /// Fulu: a single attestation with a non-zero `AttestationData.index` is @@ -1580,6 +1597,7 @@ mod tests { let mut tile = make_tile_at_wall_slot(31); seed_tile_with_keys(&mut tile, 128, 0); let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); let imm = seed_immutable(&tile); let bbr = tile.last_applied_block_root; let mut buf = test_signing::sign_single_attestation( @@ -1594,7 +1612,7 @@ mod tests { ); // AttestationData.index @ buf[24..32]; non-zero is illegal post-Electra. buf[24] = 1; - assert_eq!(tile.handle_attestation(&buf), Feedback::Reject(None)); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Reject(None)); } /// Spec `validate_on_attestation`: a current-slot vote is held until the @@ -1604,6 +1622,7 @@ mod tests { let mut tile = make_tile_at_wall_slot(31); seed_tile_with_keys(&mut tile, 128, 0); let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); // Make the committee slot the current slot → the vote must defer. tile.ticker.set_current_slot(slot); let imm = seed_immutable(&tile); @@ -1618,7 +1637,7 @@ mod tests { bbr, &imm, ); - assert_eq!(tile.handle_attestation(&buf), Feedback::Accept(None)); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); // Deferred: not yet folded into the tracker. assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, [0u8; 32]); let n = tile.head_validator_count(); @@ -1626,6 +1645,90 @@ mod tests { assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, bbr); } + /// Spec [REJECT]: an attestation must arrive on the subnet its committee + /// maps to. + #[test] + fn single_att_wrong_subnet_rejected() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, (subnet + 1) % 64), Feedback::Reject(None)); + // The reject must not have marked the attester seen. + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + } + + /// Spec [IGNORE]: at most one attestation per (attester, target epoch). + #[test] + fn single_att_repeat_attester_epoch_ignored() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); + } + + /// A rejected attestation must not mark the attester seen, or a forged + /// message would censor the validator's honest vote for the epoch. + #[test] + fn single_att_failed_validation_does_not_mark_seen() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + // Signed with sk 1; validator 0's registry key is pubkey_pk(0). + let bad = test_signing::sign_single_attestation( + 1, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&bad, subnet), Feedback::Reject(None)); + + let honest = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&honest, subnet), Feedback::Accept(None)); + } + /// Marking a validator equivocating zeroes its live vote and blocks future /// votes (spec `equivocating_indices` exclusion). #[test] diff --git a/crates/beacon_state/tile/src/tile/gossip.rs b/crates/beacon_state/tile/src/tile/gossip.rs index e9fefc2f..27b2b715 100644 --- a/crates/beacon_state/tile/src/tile/gossip.rs +++ b/crates/beacon_state/tile/src/tile/gossip.rs @@ -1,10 +1,10 @@ use flux::spine::SpineProducers; use silver_beacon_state_data::{ - B256, ParsedAggregateAndProof, SLOTS_PER_EPOCH, Slot, StateId, StateReadView, + B256, Epoch, ParsedAggregateAndProof, SLOTS_PER_EPOCH, Slot, StateId, StateReadView, }; use silver_common::{ - BeaconStateEvent, BlockSource, EngineNewPayloadEnvelopeReq, EngineReq, GossipTopic, - MAX_BLOBS_PER_BLOCK, NewGossipMsg, PeerEvent, TCacheRead, TRead, + ATTESTATION_SUBNETS, BeaconStateEvent, BlockSource, EngineNewPayloadEnvelopeReq, EngineReq, + GossipTopic, MAX_BLOBS_PER_BLOCK, NewGossipMsg, PeerEvent, TCacheRead, TRead, metrics::timed, ssz_view::{ AttestationDataView, AttestationView, AttesterSlashingView, @@ -27,9 +27,53 @@ pub(super) enum EnvelopeCheck { Ignore, } +/// Lanes are indexed by epoch parity, so the {wall, wall-1} window maps each +/// epoch to a stable lane and rotation is a re-arm of whichever lane expired. +pub(super) struct SeenAttesters { + epochs: [Epoch; 2], + bits: [Vec; 2], +} + +impl SeenAttesters { + pub(super) fn new(validator_capacity: usize) -> Self { + let words = validator_capacity.div_ceil(64); + // Sentinels above any wall epoch, parities matching their lanes. + Self { epochs: [u64::MAX - 1, u64::MAX], bits: [vec![0; words], vec![0; words]] } + } + + pub(super) fn rotate_to(&mut self, wall_epoch: Epoch) { + for epoch in [wall_epoch.saturating_sub(1), wall_epoch] { + let lane = (epoch % 2) as usize; + if self.epochs[lane] != epoch { + self.epochs[lane] = epoch; + self.bits[lane].fill(0); + } + } + } + + pub(super) fn contains(&self, target_epoch: Epoch, validator: usize) -> bool { + let lane = (target_epoch % 2) as usize; + self.epochs[lane] == target_epoch && + self.bits[lane].get(validator / 64).is_some_and(|w| w & (1 << (validator % 64)) != 0) + } + + pub(super) fn mark(&mut self, target_epoch: Epoch, validator: usize) { + let lane = (target_epoch % 2) as usize; + debug_assert!(self.epochs[lane] == target_epoch); + if self.epochs[lane] != target_epoch { + return; + } + let bits = &mut self.bits[lane]; + if validator / 64 >= bits.len() { + bits.resize(validator / 64 + 1, 0); + } + bits[validator / 64] |= 1 << (validator % 64); + } +} + impl BeaconStateTile { #[timed] - pub(super) fn handle_attestation(&mut self, data: &[u8]) -> Feedback { + pub(super) fn handle_attestation(&mut self, data: &[u8], subnet: u64) -> Feedback { if data.len() < SINGLE_ATT_SIZE { return Feedback::Reject(None); } @@ -45,6 +89,11 @@ impl BeaconStateTile { return Feedback::Ignore; } + self.seen_attesters.rotate_to(wall / SLOTS_PER_EPOCH); + if self.seen_attesters.contains(target_epoch, attester_index) { + return Feedback::Ignore; + } + // Pre-Gloas single attestations encode the committee in // `committee_index`, so `AttestationData.index` must be 0. Gloas widens // it to a payload-status bit (`index == 1` ⇒ payload present). @@ -76,6 +125,15 @@ impl BeaconStateTile { if committee_index >= shuffling.committees_per_slot { return Feedback::Reject(None); } + if subnet != + compute_subnet_for_attestation( + shuffling.committees_per_slot, + att_slot, + committee_index, + ) + { + return Feedback::Reject(None); + } let committee = shuffling.committee(att_slot, committee_index); if !committee.contains(&(attester_index as u32)) { return Feedback::Reject(None); @@ -105,6 +163,8 @@ impl BeaconStateTile { let n = self.head_validator_count(); self.record_or_defer_vote(vote, n); + self.seen_attesters.mark(target_epoch, attester_index); + Feedback::Accept(None) } @@ -607,7 +667,7 @@ impl BeaconStateTile { GossipTopic::BeaconBlock => { self.apply_block(data, read, BlockSource::Gossip, pre_verified, producers) } - GossipTopic::BeaconAttestation(_) => self.handle_attestation(data), + GossipTopic::BeaconAttestation(subnet) => self.handle_attestation(data, subnet), GossipTopic::BeaconAggregateAndProof => self.handle_aggregate_and_proof(data), GossipTopic::VoluntaryExit => self.handle_voluntary_exit(data), GossipTopic::ProposerSlashing => self.handle_proposer_slashing(data), @@ -668,3 +728,12 @@ pub(super) fn is_aggregator(committee_len: usize, selection_proof: &[u8; 96]) -> let h = merkle::sha256(selection_proof); u64::from_le_bytes(h[0..8].try_into().unwrap()) % modulo == 0 } + +pub(super) fn compute_subnet_for_attestation( + committees_per_slot: usize, + slot: Slot, + committee_index: usize, +) -> u64 { + let committees_since_epoch_start = committees_per_slot as u64 * (slot % SLOTS_PER_EPOCH); + (committees_since_epoch_start + committee_index as u64) % ATTESTATION_SUBNETS as u64 +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index d3ab41cb..9dcc3e49 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -3,9 +3,10 @@ extern crate self as silver_common; pub use crate::{ error::Error, gossip::{ - GOSSIP_TOPIC_COUNTER_SLOTS, GossipTopic, MAX_GOSSIP_COMPRESSED_PAYLOAD_SIZE, - MAX_GOSSIP_FRAME_SIZE, MAX_GOSSIP_UNCOMPRESSED_PAYLOAD_SIZE, MESSAGE_ID_LEN, MessageId, - MessageIdHasher, gossip_topic_for_counter_slot, msg_id_invalid_snappy, msg_id_valid_snappy, + ATTESTATION_SUBNETS, GOSSIP_TOPIC_COUNTER_SLOTS, GossipTopic, + MAX_GOSSIP_COMPRESSED_PAYLOAD_SIZE, MAX_GOSSIP_FRAME_SIZE, + MAX_GOSSIP_UNCOMPRESSED_PAYLOAD_SIZE, MESSAGE_ID_LEN, MessageId, MessageIdHasher, + gossip_topic_for_counter_slot, msg_id_invalid_snappy, msg_id_valid_snappy, }, id::{Keypair, PeerId, decode_protobuf_pubkey, encode_secp256k1_protobuf}, identity::{ From efb61480cdc0ee3d111cec873dda859ce2788b19 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 12 Aug 2026 15:46:16 +0100 Subject: [PATCH 03/13] Aggregate verified single attestations in a bounded in-tile pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each (slot, committee index, data root) entry accumulates participant bits and an incrementally-added BLS aggregate signature from gossip-verified singles, serialized on demand as a one-committee Electra/Fulu Attestation — the exact shape the future GET /eth/v2/validator/aggregate_attestation endpoint returns. Retention is the current and previous slot, pruned on the slot tick; the retention floor also gates inserts between ticks. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/test_signing.rs | 17 +- crates/beacon_state/tile/src/tile.rs | 69 +++- .../tile/src/tile/attestation_pool.rs | 364 ++++++++++++++++++ crates/beacon_state/tile/src/tile/gossip.rs | 24 +- 4 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 crates/beacon_state/tile/src/tile/attestation_pool.rs diff --git a/crates/beacon_state/tile/src/test_signing.rs b/crates/beacon_state/tile/src/test_signing.rs index a0f53035..5cfbfed3 100644 --- a/crates/beacon_state/tile/src/test_signing.rs +++ b/crates/beacon_state/tile/src/test_signing.rs @@ -248,15 +248,20 @@ pub fn sign_single_attestation( buf[104..112].copy_from_slice(&target_epoch.to_le_bytes()); buf[112..144].copy_from_slice(&target_root); - let fv = test_fork_version(target_epoch); - let data: &[u8; 128] = buf[16..144].try_into().unwrap(); - let object_root = ssz_hash::hash_attestation_data(data); + resign_single_attestation(sk_idx, &mut buf, imm); + buf +} + +/// Recompute the signature over the buffer's current `AttestationData`, for +/// tests that mutate data fields after `sign_single_attestation`. +pub fn resign_single_attestation(sk_idx: usize, buf: &mut [u8; SINGLE_ATT_SIZE], imm: &Immutable) { + let data: [u8; 128] = buf[16..144].try_into().unwrap(); + let fv = test_fork_version(SingleAttestationView::target_epoch(buf)); let domain = bls::compute_domain(bls::DOMAIN_BEACON_ATTESTER, fv, &imm.genesis_validators_root); - let signing_root = bls::compute_signing_root(&object_root, &domain); + let signing_root = bls::compute_signing_root(&ssz_hash::hash_attestation_data(&data), &domain); let sig = sign(sk_idx, &signing_root); buf[144..240].copy_from_slice(&sig); - debug_assert_eq!(SingleAttestationView::signature(&buf), &sig); - buf + debug_assert_eq!(SingleAttestationView::signature(buf), &sig); } /// Build a fully signed `SignedAggregateAndProof` from a single-signer diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 2af1eda6..74a0b8cb 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -22,10 +22,14 @@ use crate::{ bls, fork_choice::{FORK_CHOICE_NODES_HINT, ForkChoice, PayloadStatus}, merkle, ssz_hash, stf, - tile::{gossip::SeenAttesters, orphan_pool::PendingBlock, shuffling_cache::ShufflingCache}, + tile::{ + attestation_pool::AttestationPool, gossip::SeenAttesters, orphan_pool::PendingBlock, + shuffling_cache::ShufflingCache, + }, weak_subjectivity::{weak_subjectivity_period_fulu, weak_subjectivity_period_gloas}, }; +mod attestation_pool; mod block; mod finalize; mod fork_choice; @@ -116,6 +120,7 @@ pub struct BeaconStateTile { fork_choice: ForkChoice, shuffling_cache: Box, seen_attesters: SeenAttesters, + attestation_pool: AttestationPool, /// Highest finalized slot PM has announced as a sync target — bounds the /// data-availability requirement while range sync back-fills. @@ -215,6 +220,7 @@ impl BeaconStateTile { fork_choice: ForkChoice::default(), shuffling_cache: ShufflingCache::with_capacity(val_cap), seen_attesters: SeenAttesters::new(val_cap), + attestation_pool: AttestationPool::new(), last_applied: anchor, last_applied_block_root: [0u8; 32], initial_status_emitted: false, @@ -513,6 +519,7 @@ impl BeaconStateTile { fn slot_tick(&mut self, slot: Slot) -> bool { let advanced = self.on_slot_start(slot); self.fork_choice_tick(); + self.attestation_pool.prune_before(slot.saturating_sub(1)); advanced } @@ -784,7 +791,7 @@ mod tests { use silver_common::{ GossipTopic, MessageId, P2pStreamId, StreamProtocol, TCache, TCacheProducer, TProducer, ssz_view::{ - PROPOSER_SLASHING_SIZE, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, + AttestationView, PROPOSER_SLASHING_SIZE, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, SIGNED_VOLUNTARY_EXIT_SIZE, SignedAggregateAndProofView, SingleAttestationView, }, }; @@ -1670,7 +1677,8 @@ mod tests { assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); } - /// Spec [IGNORE]: at most one attestation per (attester, target epoch). + /// Spec [IGNORE]: at most one attestation per (attester, target epoch) — + /// byte-identical or not — and the ignored resend never reaches the pool. #[test] fn single_att_repeat_attester_epoch_ignored() { let mut tile = make_tile_at_wall_slot(31); @@ -1679,7 +1687,7 @@ mod tests { let subnet = expected_subnet(&tile, slot, ci); let imm = seed_immutable(&tile); let bbr = tile.last_applied_block_root; - let buf = test_signing::sign_single_attestation( + let mut buf = test_signing::sign_single_attestation( 0, 0, ci as u64, @@ -1690,7 +1698,22 @@ mod tests { &imm, ); assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + let data_root = + ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + let first = tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).unwrap(); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); + assert_eq!(tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).unwrap(), first); + + // Same attester+epoch, different source epoch (the one AttestationData + // field the handler doesn't validate): first-seen keys on the pair, + // not the content, so the variant must not open a new pool entry. + buf[64..72].copy_from_slice(&1u64.to_le_bytes()); + test_signing::resign_single_attestation(0, &mut buf, &imm); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); + let new_root = + ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + assert_eq!(tile.attestation_pool.aggregate_ssz(slot, ci as u64, new_root), None); } /// A rejected attestation must not mark the attester seen, or a forged @@ -1729,6 +1752,44 @@ mod tests { assert_eq!(tile.handle_attestation(&honest, subnet), Feedback::Accept(None)); } + /// An accepted single attestation lands in the pool: participant bit at + /// the attester's committee position, bitlist sized to the real committee, + /// data bytes carried over verbatim. + #[test] + fn single_att_accept_inserts_into_pool() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, pos, csize) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + + let data_root = + ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + let out = tile + .attestation_pool + .aggregate_ssz(slot, ci as u64, data_root) + .expect("pooled aggregate"); + let bits = AttestationView::aggregation_bits(&out); + assert!(bits[pos / 8] & (1 << (pos % 8)) != 0); + assert_eq!(merkle::bitlist_len(bits), csize); + assert_eq!( + AttestationView::data(&out).as_bytes(), + SingleAttestationView::data(&buf).as_bytes() + ); + } + /// Marking a validator equivocating zeroes its live vote and blocks future /// votes (spec `equivocating_indices` exclusion). #[test] diff --git a/crates/beacon_state/tile/src/tile/attestation_pool.rs b/crates/beacon_state/tile/src/tile/attestation_pool.rs new file mode 100644 index 00000000..fdf23fe8 --- /dev/null +++ b/crates/beacon_state/tile/src/tile/attestation_pool.rs @@ -0,0 +1,364 @@ +use blst::min_pk::{AggregateSignature, Signature}; +use rustc_hash::FxHashMap; +use silver_beacon_state_data::{B256, Slot}; +use silver_common::ssz_view::{ + ATTESTATION_DATA_SIZE, ATTESTATION_FIXED, MAX_COMMITTEES_PER_SLOT, SINGLE_ATT_SIZE, + SingleAttestationView, +}; + +use crate::bls::VerifiedSingleAttestation; + +/// Retention is two slots (current + previous) at ≤ MAX_COMMITTEES_PER_SLOT +/// committees each; the ×4 is headroom for competing data_root variants, +/// which honest traffic keeps at ~1 per committee and which cost an +/// attacker a real committee member's one attestation per epoch. +const MAX_ENTRIES: usize = 4 * 2 * MAX_COMMITTEES_PER_SLOT; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct AggregateKey { + slot: Slot, + committee_index: u64, + data_root: B256, +} + +struct AggregateEntry { + data: [u8; ATTESTATION_DATA_SIZE], + committee_len: usize, + /// Logical participant bits only; the SSZ terminator is appended at + /// serialization so it can never read as an attester. + participant_bits: Vec, + signature: AggregateSignature, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum InsertOutcome { + Inserted, + Duplicate, + /// Slot below the retention floor — valid vote, just not aggregable. + Stale, + Full, + /// Position ≥ committee length, or length differs from the entry's. + Inconsistent, +} + +pub(super) struct AttestationPool { + entries: FxHashMap, + floor: Slot, +} + +impl AttestationPool { + pub(super) fn new() -> Self { + Self { + entries: FxHashMap::with_capacity_and_hasher(MAX_ENTRIES, Default::default()), + floor: 0, + } + } + + pub(super) fn insert_verified( + &mut self, + att: &[u8; SINGLE_ATT_SIZE], + committee_position: usize, + committee_len: usize, + verified: &VerifiedSingleAttestation, + ) -> InsertOutcome { + if committee_position >= committee_len { + return InsertOutcome::Inconsistent; + } + let slot = SingleAttestationView::slot(att); + if slot < self.floor { + return InsertOutcome::Stale; + } + let committee_index = SingleAttestationView::committee_index(att); + debug_assert!(committee_index < MAX_COMMITTEES_PER_SLOT as u64); + + let key = AggregateKey { slot, committee_index, data_root: verified.data_root }; + if let Some(entry) = self.entries.get_mut(&key) { + if entry.committee_len != committee_len { + return InsertOutcome::Inconsistent; + } + return entry.add(committee_position, &verified.signature); + } + if self.entries.len() >= MAX_ENTRIES { + return InsertOutcome::Full; + } + self.entries.insert( + key, + AggregateEntry::new( + SingleAttestationView::data(att).as_bytes(), + committee_len, + committee_position, + &verified.signature, + ), + ); + InsertOutcome::Inserted + } + + #[allow(dead_code)] // retrieval interface for the aggregate_attestation API milestone + pub(super) fn aggregate_ssz( + &self, + slot: Slot, + committee_index: u64, + data_root: B256, + ) -> Option> { + let entry = self.entries.get(&AggregateKey { slot, committee_index, data_root })?; + let bitlist_len = entry.committee_len / 8 + 1; + let mut out = Vec::with_capacity(ATTESTATION_FIXED + bitlist_len); + out.extend_from_slice(&(ATTESTATION_FIXED as u32).to_le_bytes()); + out.extend_from_slice(&entry.data); + out.extend_from_slice(&entry.signature.to_signature().to_bytes()); + out.extend_from_slice(&(1u64 << committee_index).to_le_bytes()); + out.extend_from_slice(&entry.participant_bits); + out.resize(ATTESTATION_FIXED + bitlist_len, 0); + out[ATTESTATION_FIXED + entry.committee_len / 8] |= 1 << (entry.committee_len % 8); + Some(out) + } + + pub(super) fn prune_before(&mut self, floor: Slot) { + self.floor = floor; + self.entries.retain(|key, _| key.slot >= floor); + } +} + +impl AggregateEntry { + fn new( + data: &[u8; ATTESTATION_DATA_SIZE], + committee_len: usize, + position: usize, + signature: &Signature, + ) -> Self { + let mut participant_bits = vec![0u8; committee_len.div_ceil(8)]; + participant_bits[position / 8] |= 1 << (position % 8); + Self { + data: *data, + committee_len, + participant_bits, + signature: AggregateSignature::from_signature(signature), + } + } + + fn add(&mut self, position: usize, signature: &Signature) -> InsertOutcome { + let (byte, bit) = (position / 8, 1u8 << (position % 8)); + if self.participant_bits[byte] & bit != 0 { + return InsertOutcome::Duplicate; + } + // No group check: the signature passed `verify(true, ..)` at gossip + // admission, and BLS addition is not idempotent so the bit test above + // must gate it. + self.signature.add_signature(signature, false).expect("infallible without groupcheck"); + self.participant_bits[byte] |= bit; + InsertOutcome::Inserted + } +} + +#[cfg(test)] +mod tests { + use blst::BLST_ERROR; + use silver_beacon_state_data::Immutable; + use silver_common::ssz_view::AttestationView; + + use super::*; + use crate::{bls, merkle, ssz_hash, test_signing}; + + const SLOT: u64 = 3; + + fn verified_single(sk_idx: usize) -> ([u8; SINGLE_ATT_SIZE], VerifiedSingleAttestation) { + single_with(sk_idx, |_| {}) + } + + fn single_with( + sk_idx: usize, + mutate: impl FnOnce(&mut [u8; SINGLE_ATT_SIZE]), + ) -> ([u8; SINGLE_ATT_SIZE], VerifiedSingleAttestation) { + let imm = Immutable::default(); + let mut buf = test_signing::sign_single_attestation( + sk_idx, + sk_idx as u64, + 0, + SLOT, + [0xAB; 32], + 0, + [0xAB; 32], + &imm, + ); + mutate(&mut buf); + test_signing::resign_single_attestation(sk_idx, &mut buf, &imm); + let verified = VerifiedSingleAttestation { + data_root: ssz_hash::hash_attestation_data( + SingleAttestationView::data(&buf).as_bytes(), + ), + signature: Signature::from_bytes(SingleAttestationView::signature(&buf)).unwrap(), + }; + (buf, verified) + } + + /// The message the attesters signed: same domain derivation the seeded + /// tile resolves (zero fork version, zero gvr). + fn signing_root(buf: &[u8; SINGLE_ATT_SIZE]) -> B256 { + let data_root = + ssz_hash::hash_attestation_data(SingleAttestationView::data(buf).as_bytes()); + let domain = bls::compute_domain(bls::DOMAIN_BEACON_ATTESTER, [0; 4], &[0u8; 32]); + bls::compute_signing_root(&data_root, &domain) + } + + #[test] + fn two_singles_aggregate_to_two_bits_and_verifying_signature() { + let mut pool = AttestationPool::new(); + let (a, va) = verified_single(0); + let (b, vb) = verified_single(1); + assert_eq!(pool.insert_verified(&a, 1, 4, &va), InsertOutcome::Inserted); + assert_eq!(pool.insert_verified(&b, 3, 4, &vb), InsertOutcome::Inserted); + + let out = pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(); + + assert_eq!(&out[0..4], &236u32.to_le_bytes()); + assert_eq!( + AttestationView::data(&out).as_bytes(), + SingleAttestationView::data(&a).as_bytes() + ); + assert_eq!(AttestationView::committee_bits(&out), &1u64.to_le_bytes()); + // positions 1 and 3 set, terminator at bit 4. + assert_eq!(AttestationView::aggregation_bits(&out), &[0b0001_1010]); + + let sig = Signature::from_bytes(AttestationView::signature(&out)).unwrap(); + let msg = signing_root(&a); + let pks = [&test_signing::pubkey_pk(0), &test_signing::pubkey_pk(1)]; + assert_eq!(sig.fast_aggregate_verify(true, &msg, bls::DST, &pks), BLST_ERROR::BLST_SUCCESS); + // No longer either single signer's signature. + assert_ne!( + sig.fast_aggregate_verify(true, &msg, bls::DST, &pks[..1]), + BLST_ERROR::BLST_SUCCESS + ); + } + + #[test] + fn insertion_order_does_not_change_serialized_aggregate() { + let (a, va) = verified_single(0); + let (b, vb) = verified_single(1); + + let mut fwd = AttestationPool::new(); + assert_eq!(fwd.insert_verified(&a, 0, 5, &va), InsertOutcome::Inserted); + assert_eq!(fwd.insert_verified(&b, 4, 5, &vb), InsertOutcome::Inserted); + + let mut rev = AttestationPool::new(); + assert_eq!(rev.insert_verified(&b, 4, 5, &vb), InsertOutcome::Inserted); + assert_eq!(rev.insert_verified(&a, 0, 5, &va), InsertOutcome::Inserted); + + assert_eq!( + fwd.aggregate_ssz(SLOT, 0, va.data_root).unwrap(), + rev.aggregate_ssz(SLOT, 0, va.data_root).unwrap() + ); + } + + #[test] + fn same_member_twice_is_duplicate_and_leaves_signature_unchanged() { + let mut pool = AttestationPool::new(); + let (a, va) = verified_single(0); + let (b, vb) = verified_single(1); + assert_eq!(pool.insert_verified(&a, 1, 4, &va), InsertOutcome::Inserted); + assert_eq!(pool.insert_verified(&b, 2, 4, &vb), InsertOutcome::Inserted); + let before = pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(); + + assert_eq!(pool.insert_verified(&a, 1, 4, &va), InsertOutcome::Duplicate); + assert_eq!(pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(), before); + } + + #[test] + fn equal_data_different_committee_index_stays_separate() { + let mut pool = AttestationPool::new(); + let (a, va) = verified_single(0); + // committee_index lives outside AttestationData → same data_root. + let (b, vb) = single_with(1, |buf| buf[0..8].copy_from_slice(&1u64.to_le_bytes())); + assert_eq!(va.data_root, vb.data_root); + + assert_eq!(pool.insert_verified(&a, 0, 4, &va), InsertOutcome::Inserted); + assert_eq!(pool.insert_verified(&b, 0, 4, &vb), InsertOutcome::Inserted); + + let out_a = pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(); + let out_b = pool.aggregate_ssz(SLOT, 1, vb.data_root).unwrap(); + assert_eq!(AttestationView::aggregation_bits(&out_a), &[0b0001_0001]); + assert_eq!(AttestationView::aggregation_bits(&out_b), &[0b0001_0001]); + assert_eq!(AttestationView::committee_bits(&out_b), &2u64.to_le_bytes()); + } + + #[test] + fn different_attestation_data_stays_separate() { + let mutators: [fn(&mut [u8; SINGLE_ATT_SIZE]); 4] = [ + |buf| buf[32] ^= 1, // beacon_block_root + |buf| buf[64] = 1, // source.epoch + |buf| buf[112] ^= 1, // target.root + |buf| buf[24] = 1, // Gloas payload-status index + ]; + for mutate in mutators { + let mut pool = AttestationPool::new(); + let (a, va) = verified_single(0); + let (b, vb) = single_with(1, mutate); + assert_ne!(va.data_root, vb.data_root); + + assert_eq!(pool.insert_verified(&a, 0, 4, &va), InsertOutcome::Inserted); + assert_eq!(pool.insert_verified(&b, 1, 4, &vb), InsertOutcome::Inserted); + + let out_a = pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(); + let out_b = pool.aggregate_ssz(SLOT, 0, vb.data_root).unwrap(); + assert_eq!(AttestationView::aggregation_bits(&out_a), &[0b0001_0001]); + assert_eq!(AttestationView::aggregation_bits(&out_b), &[0b0001_0010]); + } + } + + #[test] + fn out_of_range_position_and_len_mismatch_error_without_mutation() { + let mut pool = AttestationPool::new(); + let (a, va) = verified_single(0); + let (b, vb) = verified_single(1); + assert_eq!(pool.insert_verified(&a, 0, 4, &va), InsertOutcome::Inserted); + let before = pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(); + + assert_eq!(pool.insert_verified(&b, 4, 4, &vb), InsertOutcome::Inconsistent); + assert_eq!(pool.insert_verified(&b, 1, 5, &vb), InsertOutcome::Inconsistent); + assert_eq!(pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(), before); + + let mut fresh = AttestationPool::new(); + assert_eq!(fresh.insert_verified(&b, 9, 4, &vb), InsertOutcome::Inconsistent); + assert_eq!(fresh.aggregate_ssz(SLOT, 0, vb.data_root), None); + } + + #[test] + fn bitlist_terminator_crosses_byte_boundaries() { + let cases: [(usize, usize, &[u8]); 6] = [ + (7, 6, &[0b1100_0000]), // ceil(8/8)=1 byte; term bit 7 + (7, 0, &[0b1000_0001]), + (8, 7, &[0b1000_0000, 0b0000_0001]), // term overflows to byte1 bit0 + (8, 0, &[0b0000_0001, 0b0000_0001]), + (9, 8, &[0b0000_0000, 0b0000_0011]), // participant+term share byte1 + (9, 0, &[0b0000_0001, 0b0000_0010]), + ]; + for (len, pos, expect) in cases { + let mut pool = AttestationPool::new(); + let (a, va) = verified_single(0); + assert_eq!(pool.insert_verified(&a, pos, len, &va), InsertOutcome::Inserted); + + let out = pool.aggregate_ssz(SLOT, 0, va.data_root).unwrap(); + let bits = AttestationView::aggregation_bits(&out); + assert_eq!(bits, expect); + assert_eq!(out.len(), ATTESTATION_FIXED + expect.len()); + // Closes the loop with the production bitlist reader — the + // terminator can never read as an attester. + assert_eq!(merkle::bitlist_len(bits), len); + } + } + + #[test] + fn prune_before_removes_expired_slots() { + let mut pool = AttestationPool::new(); + let (a, va) = verified_single(0); + let (b, vb) = single_with(1, |buf| buf[16..24].copy_from_slice(&4u64.to_le_bytes())); + assert_eq!(pool.insert_verified(&a, 0, 4, &va), InsertOutcome::Inserted); + assert_eq!(pool.insert_verified(&b, 1, 4, &vb), InsertOutcome::Inserted); + + pool.prune_before(4); + + assert_eq!(pool.aggregate_ssz(SLOT, 0, va.data_root), None); + assert!(pool.aggregate_ssz(4, 0, vb.data_root).is_some()); + // The floor also gates inserts between prunes. + assert_eq!(pool.insert_verified(&a, 0, 4, &va), InsertOutcome::Stale); + } +} diff --git a/crates/beacon_state/tile/src/tile/gossip.rs b/crates/beacon_state/tile/src/tile/gossip.rs index 27b2b715..7fd6068b 100644 --- a/crates/beacon_state/tile/src/tile/gossip.rs +++ b/crates/beacon_state/tile/src/tile/gossip.rs @@ -17,6 +17,7 @@ use silver_common::{ use super::{ ATTESTATION_PROPAGATION_SLOT_RANGE, BY_ROOT_REQUEST_ID, BeaconStateTile, Feedback, Producers, + attestation_pool::InsertOutcome, orphan_pool::{PendingBlock, has_room}, }; use crate::{bls, merkle, ssz_hash, stf, validate}; @@ -135,22 +136,33 @@ impl BeaconStateTile { return Feedback::Reject(None); } let committee = shuffling.committee(att_slot, committee_index); - if !committee.contains(&(attester_index as u32)) { + let Some(committee_position) = committee.iter().position(|&v| v == attester_index as u32) + else { return Feedback::Reject(None); - } + }; + let committee_len = committee.len(); if attester_index >= view.validators.count() { return Feedback::Reject(None); } let fork_version = view.epoch.fork_version_at(target_epoch); - if bls::verify_single_attestation( + let Some(verified) = bls::verify_single_attestation( buf, view.validators.pubkey_decompressed(attester_index), fork_version, &view.imm.genesis_validators_root, - ) - .is_none() - { + ) else { return Feedback::Reject(None); + }; + + let outcome = self.attestation_pool.insert_verified( + buf, + committee_position, + committee_len, + &verified, + ); + debug_assert!(outcome != InsertOutcome::Inconsistent); + if outcome == InsertOutcome::Full { + tracing::debug!(slot = att_slot, committee = committee_index, "attestation pool full"); } let vote = stf::AttestationVote { From fc522bd5bcb37a9b8251791d9c411b360e6b4d58 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 12 Aug 2026 18:26:46 +0100 Subject: [PATCH 04/13] Prove pooled aggregates against the live aggregate-and-proof path Split test_signing's aggregate builder into build_attestation + wrap_aggregate_and_proof so tests can wrap an arbitrary inner Attestation, and add the end-to-end oracle: two gossip-verified singles from distinct-key committee members, pooled, serialized, wrapped with a selection proof and outer signature, accepted by handle_aggregate_and_proof's full three-signature batch verification. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/test_signing.rs | 146 +++++++++++-------- crates/beacon_state/tile/src/tile.rs | 62 ++++++++ 2 files changed, 148 insertions(+), 60 deletions(-) diff --git a/crates/beacon_state/tile/src/test_signing.rs b/crates/beacon_state/tile/src/test_signing.rs index 5cfbfed3..8cbdd72a 100644 --- a/crates/beacon_state/tile/src/test_signing.rs +++ b/crates/beacon_state/tile/src/test_signing.rs @@ -9,8 +9,8 @@ use silver_beacon_state_data::{ B256, BLSPubkey, BeaconBlockHeader, Fork, Immutable, SLOTS_PER_EPOCH, }; use silver_common::ssz_view::{ - AttestationDataView, IndexedAttestationView, PROPOSER_SLASHING_SIZE, ProposerSlashingView, - SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, SIGNED_VOLUNTARY_EXIT_SIZE, SINGLE_ATT_SIZE, + AttestationView, IndexedAttestationView, PROPOSER_SLASHING_SIZE, ProposerSlashingView, + SIGNED_BLS_CHANGE_SIZE, SIGNED_VOLUNTARY_EXIT_SIZE, SINGLE_ATT_SIZE, SignedAggregateAndProofView, SignedBlsToExecutionChangeView, SignedVoluntaryExitView, SingleAttestationView, }; @@ -264,99 +264,125 @@ pub fn resign_single_attestation(sk_idx: usize, buf: &mut [u8; SINGLE_ATT_SIZE], debug_assert_eq!(SingleAttestationView::signature(buf), &sig); } -/// Build a fully signed `SignedAggregateAndProof` from a single-signer -/// committee. `committee_index` is the (single) bit in `committee_bits`; -/// the bit at position `aggregator_pos_in_committee` is the only set bit in -/// `aggregation_bits` — that participant signs the inner aggregate, the same -/// validator is the aggregator. -#[allow(clippy::too_many_arguments)] -pub fn sign_aggregate_and_proof( +/// Build a single-signer `Attestation`: `committee_index` is the (single) +/// bit in `committee_bits`; the bit at `participant_pos` is the only set bit +/// in `aggregation_bits` — that participant signs the aggregate. +pub fn build_attestation( sk_idx: usize, - aggregator_index: u64, slot: u64, target_epoch: u64, beacon_block_root: B256, target_root: B256, committee_index: usize, - aggregator_pos_in_committee: usize, + participant_pos: usize, committee_size: usize, imm: &Immutable, ) -> Vec { - // Bitlist[committee_size] with a single set bit at pos - // `aggregator_pos_in_committee`, plus terminator bit at position - // `committee_size`. Length = ceil((committee_size+1)/8). + // Fixed part 236B; trailing Bitlist[committee_size] with terminator bit + // at position `committee_size` → length = ceil((committee_size+1)/8). let bl_len = (committee_size + 1).div_ceil(8); - let mut agg_bits = vec![0u8; bl_len]; - let pos = aggregator_pos_in_committee; - agg_bits[pos / 8] |= 1 << (pos % 8); - // Terminator. - let term = committee_size; - agg_bits[term / 8] |= 1 << (term % 8); - - // SignedAggregateAndProof fixed part = 444B; aggregation_bits trail. - let mut buf = vec![0u8; SIGNED_AGG_PROOF_MIN + bl_len]; - - // Outer fixed: offset to message (4) = 100, signature (96) = [4..100). - buf[0..4].copy_from_slice(&100u32.to_le_bytes()); - - // AggregateAndProof: aggregator_index(8) at 100, offset to aggregate(4) - // = 108 (relative-to-message = 108), selection_proof(96) at 112. - buf[100..108].copy_from_slice(&aggregator_index.to_le_bytes()); - buf[108..112].copy_from_slice(&108u32.to_le_bytes()); + let mut buf = vec![0u8; 236 + bl_len]; - // Attestation (variable, starts at 208): - // offset to aggregation_bits(4) = 236 (rel-to-attestation = 236, absolute 444). - buf[208..212].copy_from_slice(&236u32.to_le_bytes()); + // offset to aggregation_bits(4) = 236. + buf[0..4].copy_from_slice(&236u32.to_le_bytes()); - // AttestationData[212..340]: slot, index(=0), beacon_block_root, source, + // AttestationData[4..132]: slot, index(=0), beacon_block_root, source, // target. - buf[212..220].copy_from_slice(&slot.to_le_bytes()); - buf[228..260].copy_from_slice(&beacon_block_root); - buf[300..308].copy_from_slice(&target_epoch.to_le_bytes()); - buf[308..340].copy_from_slice(&target_root); + buf[4..12].copy_from_slice(&slot.to_le_bytes()); + buf[20..52].copy_from_slice(&beacon_block_root); + buf[92..100].copy_from_slice(&target_epoch.to_le_bytes()); + buf[100..132].copy_from_slice(&target_root); - // committee_bits[436..444]: single bit at `committee_index`. - buf[436 + committee_index / 8] |= 1 << (committee_index % 8); + // committee_bits[228..236]: single bit at `committee_index`. + buf[228 + committee_index / 8] |= 1 << (committee_index % 8); - // aggregation_bits at [444..444+bl_len]. - buf[444..444 + bl_len].copy_from_slice(&agg_bits); + // aggregation_bits at [236..]: participant bit, then terminator. + buf[236 + participant_pos / 8] |= 1 << (participant_pos % 8); + buf[236 + committee_size / 8] |= 1 << (committee_size % 8); - // Sign the inner aggregate (single signer = aggregator). let fv = test_fork_version(target_epoch); - let data: &[u8; 128] = buf[212..340].try_into().unwrap(); + let data: &[u8; 128] = buf[4..132].try_into().unwrap(); let data_root = ssz_hash::hash_attestation_data(data); let domain_att = bls::compute_domain(bls::DOMAIN_BEACON_ATTESTER, fv, &imm.genesis_validators_root); - let sr_att = bls::compute_signing_root(&data_root, &domain_att); - let inner_sig = sign(sk_idx, &sr_att); - buf[340..436].copy_from_slice(&inner_sig); + let sig = sign(sk_idx, &bls::compute_signing_root(&data_root, &domain_att)); + buf[132..228].copy_from_slice(&sig); + + debug_assert_eq!(AttestationView::signature(&buf), &sig); + debug_assert_eq!(AttestationView::data(&buf).target_epoch(), target_epoch); + buf +} + +/// Wrap an existing inner `Attestation` SSZ buffer in a fully signed +/// `SignedAggregateAndProof`: `sk_idx` (the aggregator's key) signs the +/// selection proof over the attestation's slot and the outer signature; +/// the inner aggregate keeps whatever signature it carries. +pub fn wrap_aggregate_and_proof( + sk_idx: usize, + aggregator_index: u64, + attestation: &[u8], + imm: &Immutable, +) -> Vec { + // Outer fixed: offset to message(4) = 100, signature(96) = [4..100). + // AggregateAndProof: aggregator_index(8) at 100, offset to aggregate(4) + // = 108 (relative-to-message = 108), selection_proof(96) at 112. + let mut buf = vec![0u8; 208 + attestation.len()]; + buf[0..4].copy_from_slice(&100u32.to_le_bytes()); + buf[100..108].copy_from_slice(&aggregator_index.to_le_bytes()); + buf[108..112].copy_from_slice(&108u32.to_le_bytes()); + buf[208..].copy_from_slice(attestation); + + let data = AttestationView::data(attestation); + let fv = test_fork_version(data.target_epoch()); // Selection proof: signer = aggregator, msg = htr(uint64(slot)). - let slot_root = merkle::uint64_chunk(slot); + let slot_root = merkle::uint64_chunk(data.slot()); let domain_sp = bls::compute_domain(bls::DOMAIN_SELECTION_PROOF, fv, &imm.genesis_validators_root); - let sr_sp = bls::compute_signing_root(&slot_root, &domain_sp); - let selection_proof = sign(sk_idx, &sr_sp); + let selection_proof = sign(sk_idx, &bls::compute_signing_root(&slot_root, &domain_sp)); buf[112..208].copy_from_slice(&selection_proof); - // Outer AggregateAndProof sig. - let aggregate_bytes = SignedAggregateAndProofView::aggregate(&buf); let agg_proof_root = ssz_hash::hash_tree_root_aggregate_and_proof( aggregator_index, - aggregate_bytes, + &buf[208..], &selection_proof, fv == imm.gloas_fork_version, ); let domain_aap = bls::compute_domain(bls::DOMAIN_AGGREGATE_AND_PROOF, fv, &imm.genesis_validators_root); - let sr_aap = bls::compute_signing_root(&agg_proof_root, &domain_aap); - let outer_sig = sign(sk_idx, &sr_aap); + let outer_sig = sign(sk_idx, &bls::compute_signing_root(&agg_proof_root, &domain_aap)); buf[4..100].copy_from_slice(&outer_sig); debug_assert_eq!(SignedAggregateAndProofView::signature(&buf), &outer_sig); - debug_assert_eq!( - AttestationDataView::new(buf[212..340].try_into().unwrap()).target_epoch(), - target_epoch - ); buf } + +/// Build a fully signed `SignedAggregateAndProof` from a single-signer +/// committee — the participant at `aggregator_pos_in_committee` signs the +/// inner aggregate, and the same validator is the aggregator. +#[allow(clippy::too_many_arguments)] +pub fn sign_aggregate_and_proof( + sk_idx: usize, + aggregator_index: u64, + slot: u64, + target_epoch: u64, + beacon_block_root: B256, + target_root: B256, + committee_index: usize, + aggregator_pos_in_committee: usize, + committee_size: usize, + imm: &Immutable, +) -> Vec { + let att = build_attestation( + sk_idx, + slot, + target_epoch, + beacon_block_root, + target_root, + committee_index, + aggregator_pos_in_committee, + committee_size, + imm, + ); + wrap_aggregate_and_proof(sk_idx, aggregator_index, &att, imm) +} diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 74a0b8cb..8e43fc5c 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -1953,6 +1953,68 @@ mod tests { assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); } + /// First committee (skipping the wall slot) holding two members whose + /// registry keys differ (vi % 3), so the aggregate is genuinely multi-key. + fn find_committee_with_two_signers(tile: &BeaconStateTile) -> (Slot, usize, u32, u32) { + let shuffled = tile.shuffling_cache.shuffled_by_epoch(0).expect("shuffling for epoch 0"); + let shuffling = stf::EpochShuffling::new(shuffled, tile.head_validator_count()); + for s in 0..SLOTS_PER_EPOCH - 1 { + for ci in 0..shuffling.committees_per_slot { + let c = shuffling.committee(s, ci); + for (i, &a) in c.iter().enumerate() { + if let Some(&b) = c[i + 1..].iter().find(|&&b| b % 3 != a % 3) { + return (s, ci, a, b); + } + } + } + } + panic!("two distinct-key members in some committee") + } + + #[test] + fn pool_aggregate_accepted_by_aggregate_and_proof_path() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + let subnet = expected_subnet(&tile, slot, ci); + + let single = |vi: u32| { + test_signing::sign_single_attestation( + vi as usize % 3, + vi as u64, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ) + }; + let a = single(vi_a); + let data_root = ssz_hash::hash_attestation_data(SingleAttestationView::data(&a).as_bytes()); + assert_eq!(tile.handle_attestation(&a, subnet), Feedback::Accept(None)); + assert_eq!(tile.handle_attestation(&single(vi_b), subnet), Feedback::Accept(None)); + + let aggregate = tile + .attestation_pool + .aggregate_ssz(slot, ci as u64, data_root) + .expect("pooled aggregate"); + + // Committee of 4 < 16 ⇒ any member trivially passes is_aggregator. + let wrapped = test_signing::wrap_aggregate_and_proof( + vi_a as usize % 3, + vi_a as u64, + &aggregate, + &imm, + ); + // Accept requires verify_aggregate_and_proof_sigs: selection proof, + // outer signature, and the pooled aggregate signature against the two + // participants' aggregated registry pubkeys. + assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Accept(None)); + } + // ── finalization (deposit append lands on the delta, not the base) ── /// A `PendingDeposit` for a brand-new validator (spec key 0), signed under From fca059f6d5f314e36e2db992f3c692bfa51e9c03 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 12 Aug 2026 18:34:45 +0100 Subject: [PATCH 05/13] Review polish: derive test seam offsets, scope the pool comment Use ATTESTATION_FIXED and SIGNED_AGG_PROOF_MIN - ATTESTATION_FIXED for the offsets where build_attestation and wrap_aggregate_and_proof meet the inner attestation bytes, and state the pool's no-group-check rationale via VerifiedSingleAttestation's contract rather than the gossip call site. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/test_signing.rs | 31 ++++++++++--------- .../tile/src/tile/attestation_pool.rs | 6 ++-- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/beacon_state/tile/src/test_signing.rs b/crates/beacon_state/tile/src/test_signing.rs index 8cbdd72a..df2e0512 100644 --- a/crates/beacon_state/tile/src/test_signing.rs +++ b/crates/beacon_state/tile/src/test_signing.rs @@ -9,10 +9,10 @@ use silver_beacon_state_data::{ B256, BLSPubkey, BeaconBlockHeader, Fork, Immutable, SLOTS_PER_EPOCH, }; use silver_common::ssz_view::{ - AttestationView, IndexedAttestationView, PROPOSER_SLASHING_SIZE, ProposerSlashingView, - SIGNED_BLS_CHANGE_SIZE, SIGNED_VOLUNTARY_EXIT_SIZE, SINGLE_ATT_SIZE, - SignedAggregateAndProofView, SignedBlsToExecutionChangeView, SignedVoluntaryExitView, - SingleAttestationView, + ATTESTATION_FIXED, AttestationView, IndexedAttestationView, PROPOSER_SLASHING_SIZE, + ProposerSlashingView, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, SIGNED_VOLUNTARY_EXIT_SIZE, + SINGLE_ATT_SIZE, SignedAggregateAndProofView, SignedBlsToExecutionChangeView, + SignedVoluntaryExitView, SingleAttestationView, }; use crate::{ @@ -278,13 +278,12 @@ pub fn build_attestation( committee_size: usize, imm: &Immutable, ) -> Vec { - // Fixed part 236B; trailing Bitlist[committee_size] with terminator bit - // at position `committee_size` → length = ceil((committee_size+1)/8). + // Trailing Bitlist[committee_size] with terminator bit at position + // `committee_size` → length = ceil((committee_size+1)/8). let bl_len = (committee_size + 1).div_ceil(8); - let mut buf = vec![0u8; 236 + bl_len]; + let mut buf = vec![0u8; ATTESTATION_FIXED + bl_len]; - // offset to aggregation_bits(4) = 236. - buf[0..4].copy_from_slice(&236u32.to_le_bytes()); + buf[0..4].copy_from_slice(&(ATTESTATION_FIXED as u32).to_le_bytes()); // AttestationData[4..132]: slot, index(=0), beacon_block_root, source, // target. @@ -296,9 +295,9 @@ pub fn build_attestation( // committee_bits[228..236]: single bit at `committee_index`. buf[228 + committee_index / 8] |= 1 << (committee_index % 8); - // aggregation_bits at [236..]: participant bit, then terminator. - buf[236 + participant_pos / 8] |= 1 << (participant_pos % 8); - buf[236 + committee_size / 8] |= 1 << (committee_size % 8); + // aggregation_bits: participant bit, then terminator. + buf[ATTESTATION_FIXED + participant_pos / 8] |= 1 << (participant_pos % 8); + buf[ATTESTATION_FIXED + committee_size / 8] |= 1 << (committee_size % 8); let fv = test_fork_version(target_epoch); let data: &[u8; 128] = buf[4..132].try_into().unwrap(); @@ -323,14 +322,16 @@ pub fn wrap_aggregate_and_proof( attestation: &[u8], imm: &Immutable, ) -> Vec { + const PREFIX: usize = SIGNED_AGG_PROOF_MIN - ATTESTATION_FIXED; + // Outer fixed: offset to message(4) = 100, signature(96) = [4..100). // AggregateAndProof: aggregator_index(8) at 100, offset to aggregate(4) // = 108 (relative-to-message = 108), selection_proof(96) at 112. - let mut buf = vec![0u8; 208 + attestation.len()]; + let mut buf = vec![0u8; PREFIX + attestation.len()]; buf[0..4].copy_from_slice(&100u32.to_le_bytes()); buf[100..108].copy_from_slice(&aggregator_index.to_le_bytes()); buf[108..112].copy_from_slice(&108u32.to_le_bytes()); - buf[208..].copy_from_slice(attestation); + buf[PREFIX..].copy_from_slice(attestation); let data = AttestationView::data(attestation); let fv = test_fork_version(data.target_epoch()); @@ -344,7 +345,7 @@ pub fn wrap_aggregate_and_proof( let agg_proof_root = ssz_hash::hash_tree_root_aggregate_and_proof( aggregator_index, - &buf[208..], + &buf[PREFIX..], &selection_proof, fv == imm.gloas_fork_version, ); diff --git a/crates/beacon_state/tile/src/tile/attestation_pool.rs b/crates/beacon_state/tile/src/tile/attestation_pool.rs index fdf23fe8..a67d4f07 100644 --- a/crates/beacon_state/tile/src/tile/attestation_pool.rs +++ b/crates/beacon_state/tile/src/tile/attestation_pool.rs @@ -141,9 +141,9 @@ impl AggregateEntry { if self.participant_bits[byte] & bit != 0 { return InsertOutcome::Duplicate; } - // No group check: the signature passed `verify(true, ..)` at gossip - // admission, and BLS addition is not idempotent so the bit test above - // must gate it. + // No group check: `VerifiedSingleAttestation` guarantees a + // subgroup-checked signature. BLS addition is not idempotent, so the + // bit test above must gate it. self.signature.add_signature(signature, false).expect("infallible without groupcheck"); self.participant_bits[byte] |= bit; InsertOutcome::Inserted From 7521871254b1d505b4a41fdffae03fbd1b329b8f Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Wed, 12 Aug 2026 18:55:02 +0100 Subject: [PATCH 06/13] Time the new per-attestation and per-tick aggregation work insert_verified (EC add + map probe per accepted attestation), aggregate_ssz (alloc + G2 compression), prune_before (per-slot retain) and verify_single_attestation (the gossip path's pairing verify) now carry #[timed], so surfer splits handle_attestation into its verify and pool phases. Bit-level helpers, pure arithmetic and the seen-set stay untimed, matching the granularity the codebase times elsewhere. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/bls.rs | 1 + .../beacon_state/tile/src/tile/attestation_pool.rs | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/beacon_state/tile/src/bls.rs b/crates/beacon_state/tile/src/bls.rs index ee9448e7..3d46075f 100644 --- a/crates/beacon_state/tile/src/bls.rs +++ b/crates/beacon_state/tile/src/bls.rs @@ -426,6 +426,7 @@ pub struct VerifiedSingleAttestation { /// Verify a single-attester `SingleAttestation` (gossip subnet form). Used /// on the gossip hot path; the body-included aggregate path goes through /// `stf::validate_attestations` + `SigBatch`. +#[timed] pub fn verify_single_attestation( att: &[u8; SINGLE_ATT_SIZE], attester_pubkey: &PublicKey, diff --git a/crates/beacon_state/tile/src/tile/attestation_pool.rs b/crates/beacon_state/tile/src/tile/attestation_pool.rs index a67d4f07..aef3207e 100644 --- a/crates/beacon_state/tile/src/tile/attestation_pool.rs +++ b/crates/beacon_state/tile/src/tile/attestation_pool.rs @@ -1,9 +1,12 @@ use blst::min_pk::{AggregateSignature, Signature}; use rustc_hash::FxHashMap; use silver_beacon_state_data::{B256, Slot}; -use silver_common::ssz_view::{ - ATTESTATION_DATA_SIZE, ATTESTATION_FIXED, MAX_COMMITTEES_PER_SLOT, SINGLE_ATT_SIZE, - SingleAttestationView, +use silver_common::{ + metrics::timed, + ssz_view::{ + ATTESTATION_DATA_SIZE, ATTESTATION_FIXED, MAX_COMMITTEES_PER_SLOT, SINGLE_ATT_SIZE, + SingleAttestationView, + }, }; use crate::bls::VerifiedSingleAttestation; @@ -54,6 +57,7 @@ impl AttestationPool { } } + #[timed] pub(super) fn insert_verified( &mut self, att: &[u8; SINGLE_ATT_SIZE], @@ -94,6 +98,7 @@ impl AttestationPool { } #[allow(dead_code)] // retrieval interface for the aggregate_attestation API milestone + #[timed] pub(super) fn aggregate_ssz( &self, slot: Slot, @@ -113,6 +118,7 @@ impl AttestationPool { Some(out) } + #[timed] pub(super) fn prune_before(&mut self, floor: Slot) { self.floor = floor; self.entries.retain(|key, _| key.slot >= floor); From 59d3a4144a6b97abfc7032c639263bef6c248573 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 13 Aug 2026 10:10:33 +0100 Subject: [PATCH 07/13] Give SeenAttesters its own module and direct lifecycle tests Review feedback: a stateful component with its own storage and rotation lifecycle belongs in its own file, matching attestation_pool and shuffling_cache, not inline in the gossip handler. The new direct tests cover what handler-level tests (all epoch 0) never exercised: lane clearing on epoch advance, re-markability two epochs later, out-of-window epochs reading empty, and growth past construction capacity. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/tile.rs | 5 +- crates/beacon_state/tile/src/tile/gossip.rs | 46 +------ .../tile/src/tile/seen_attesters.rs | 121 ++++++++++++++++++ 3 files changed, 125 insertions(+), 47 deletions(-) create mode 100644 crates/beacon_state/tile/src/tile/seen_attesters.rs diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 8e43fc5c..79ebd40f 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -23,8 +23,8 @@ use crate::{ fork_choice::{FORK_CHOICE_NODES_HINT, ForkChoice, PayloadStatus}, merkle, ssz_hash, stf, tile::{ - attestation_pool::AttestationPool, gossip::SeenAttesters, orphan_pool::PendingBlock, - shuffling_cache::ShufflingCache, + attestation_pool::AttestationPool, orphan_pool::PendingBlock, + seen_attesters::SeenAttesters, shuffling_cache::ShufflingCache, }, weak_subjectivity::{weak_subjectivity_period_fulu, weak_subjectivity_period_gloas}, }; @@ -35,6 +35,7 @@ mod finalize; mod fork_choice; mod gossip; mod orphan_pool; +mod seen_attesters; mod shuffling_cache; #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/beacon_state/tile/src/tile/gossip.rs b/crates/beacon_state/tile/src/tile/gossip.rs index 7fd6068b..7869e92e 100644 --- a/crates/beacon_state/tile/src/tile/gossip.rs +++ b/crates/beacon_state/tile/src/tile/gossip.rs @@ -1,6 +1,6 @@ use flux::spine::SpineProducers; use silver_beacon_state_data::{ - B256, Epoch, ParsedAggregateAndProof, SLOTS_PER_EPOCH, Slot, StateId, StateReadView, + B256, ParsedAggregateAndProof, SLOTS_PER_EPOCH, Slot, StateId, StateReadView, }; use silver_common::{ ATTESTATION_SUBNETS, BeaconStateEvent, BlockSource, EngineNewPayloadEnvelopeReq, EngineReq, @@ -28,50 +28,6 @@ pub(super) enum EnvelopeCheck { Ignore, } -/// Lanes are indexed by epoch parity, so the {wall, wall-1} window maps each -/// epoch to a stable lane and rotation is a re-arm of whichever lane expired. -pub(super) struct SeenAttesters { - epochs: [Epoch; 2], - bits: [Vec; 2], -} - -impl SeenAttesters { - pub(super) fn new(validator_capacity: usize) -> Self { - let words = validator_capacity.div_ceil(64); - // Sentinels above any wall epoch, parities matching their lanes. - Self { epochs: [u64::MAX - 1, u64::MAX], bits: [vec![0; words], vec![0; words]] } - } - - pub(super) fn rotate_to(&mut self, wall_epoch: Epoch) { - for epoch in [wall_epoch.saturating_sub(1), wall_epoch] { - let lane = (epoch % 2) as usize; - if self.epochs[lane] != epoch { - self.epochs[lane] = epoch; - self.bits[lane].fill(0); - } - } - } - - pub(super) fn contains(&self, target_epoch: Epoch, validator: usize) -> bool { - let lane = (target_epoch % 2) as usize; - self.epochs[lane] == target_epoch && - self.bits[lane].get(validator / 64).is_some_and(|w| w & (1 << (validator % 64)) != 0) - } - - pub(super) fn mark(&mut self, target_epoch: Epoch, validator: usize) { - let lane = (target_epoch % 2) as usize; - debug_assert!(self.epochs[lane] == target_epoch); - if self.epochs[lane] != target_epoch { - return; - } - let bits = &mut self.bits[lane]; - if validator / 64 >= bits.len() { - bits.resize(validator / 64 + 1, 0); - } - bits[validator / 64] |= 1 << (validator % 64); - } -} - impl BeaconStateTile { #[timed] pub(super) fn handle_attestation(&mut self, data: &[u8], subnet: u64) -> Feedback { diff --git a/crates/beacon_state/tile/src/tile/seen_attesters.rs b/crates/beacon_state/tile/src/tile/seen_attesters.rs new file mode 100644 index 00000000..70c328b0 --- /dev/null +++ b/crates/beacon_state/tile/src/tile/seen_attesters.rs @@ -0,0 +1,121 @@ +use silver_beacon_state_data::Epoch; + +/// Lanes are indexed by epoch parity, so the {wall, wall-1} window maps each +/// epoch to a stable lane and rotation is a re-arm of whichever lane expired. +pub(super) struct SeenAttesters { + epochs: [Epoch; 2], + bits: [Vec; 2], +} + +impl SeenAttesters { + pub(super) fn new(validator_capacity: usize) -> Self { + let words = validator_capacity.div_ceil(64); + // Sentinels above any wall epoch, parities matching their lanes. + Self { epochs: [u64::MAX - 1, u64::MAX], bits: [vec![0; words], vec![0; words]] } + } + + pub(super) fn rotate_to(&mut self, wall_epoch: Epoch) { + for epoch in [wall_epoch.saturating_sub(1), wall_epoch] { + let lane = (epoch % 2) as usize; + if self.epochs[lane] != epoch { + self.epochs[lane] = epoch; + self.bits[lane].fill(0); + } + } + } + + pub(super) fn contains(&self, target_epoch: Epoch, validator: usize) -> bool { + let lane = (target_epoch % 2) as usize; + self.epochs[lane] == target_epoch && + self.bits[lane].get(validator / 64).is_some_and(|w| w & (1 << (validator % 64)) != 0) + } + + pub(super) fn mark(&mut self, target_epoch: Epoch, validator: usize) { + let lane = (target_epoch % 2) as usize; + debug_assert!(self.epochs[lane] == target_epoch); + if self.epochs[lane] != target_epoch { + return; + } + let bits = &mut self.bits[lane]; + if validator / 64 >= bits.len() { + bits.resize(validator / 64 + 1, 0); + } + bits[validator / 64] |= 1 << (validator % 64); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_lanes_contain_nothing() { + let seen = SeenAttesters::new(128); + assert!(!seen.contains(0, 0)); + assert!(!seen.contains(1, 127)); + } + + #[test] + fn mark_then_contains_within_window() { + let mut seen = SeenAttesters::new(128); + seen.rotate_to(1); + seen.mark(0, 7); + seen.mark(1, 7); + assert!(seen.contains(0, 7)); + assert!(seen.contains(1, 7)); + assert!(!seen.contains(0, 8)); + // Re-marking is a no-op, not a toggle. + seen.mark(0, 7); + assert!(seen.contains(0, 7)); + } + + #[test] + fn rotation_clears_only_the_expired_lane() { + let mut seen = SeenAttesters::new(128); + seen.rotate_to(0); + seen.mark(0, 3); + + // Window {0, 1}: epoch 0 survives the first advance. + seen.rotate_to(1); + assert!(seen.contains(0, 3)); + + // Window {1, 2}: epoch 0's lane is re-armed for epoch 2, so the + // attester becomes markable again. + seen.rotate_to(2); + assert!(!seen.contains(0, 3)); + assert!(!seen.contains(2, 3)); + seen.mark(2, 3); + assert!(seen.contains(2, 3)); + } + + #[test] + fn out_of_window_epochs_read_empty() { + let mut seen = SeenAttesters::new(128); + seen.rotate_to(5); + seen.mark(5, 9); + seen.mark(4, 9); + // Claimed epochs sharing a live lane's parity must not read its bits. + assert!(!seen.contains(7, 9)); + assert!(!seen.contains(3, 9)); + assert!(!seen.contains(6, 9)); + } + + #[test] + fn rotation_within_an_epoch_keeps_marks() { + let mut seen = SeenAttesters::new(128); + seen.rotate_to(4); + seen.mark(4, 11); + seen.rotate_to(4); + assert!(seen.contains(4, 11)); + } + + #[test] + fn marks_grow_past_construction_capacity() { + let mut seen = SeenAttesters::new(0); + seen.rotate_to(0); + assert!(!seen.contains(0, 200)); + seen.mark(0, 200); + assert!(seen.contains(0, 200)); + assert!(!seen.contains(0, 5000)); + } +} From ab921e9f2bf4670229a35a69c513e2d9209216a5 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 13 Aug 2026 11:20:15 +0100 Subject: [PATCH 08/13] Ignore repeat aggregators per target epoch on the aggregate topic Spec IGNORE rule: only the first valid aggregate per (aggregator index, target epoch) is processed; repeats now die on a bitset probe before any fork-choice or BLS work. SeenAttesters generalizes to SeenValidators with a second tile instance for aggregators, keeping its lifecycle tests; marking happens only on full acceptance so a forged aggregate cannot censor the aggregator's real one. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/tile.rs | 128 ++++++++++++++---- crates/beacon_state/tile/src/tile/gossip.rs | 12 +- .../{seen_attesters.rs => seen_validators.rs} | 18 +-- 3 files changed, 121 insertions(+), 37 deletions(-) rename crates/beacon_state/tile/src/tile/{seen_attesters.rs => seen_validators.rs} (89%) diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index 79ebd40f..ba97070c 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -24,7 +24,7 @@ use crate::{ merkle, ssz_hash, stf, tile::{ attestation_pool::AttestationPool, orphan_pool::PendingBlock, - seen_attesters::SeenAttesters, shuffling_cache::ShufflingCache, + seen_validators::SeenValidators, shuffling_cache::ShufflingCache, }, weak_subjectivity::{weak_subjectivity_period_fulu, weak_subjectivity_period_gloas}, }; @@ -35,7 +35,7 @@ mod finalize; mod fork_choice; mod gossip; mod orphan_pool; -mod seen_attesters; +mod seen_validators; mod shuffling_cache; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -120,7 +120,8 @@ pub struct BeaconStateTile { fork_choice: ForkChoice, shuffling_cache: Box, - seen_attesters: SeenAttesters, + seen_attesters: SeenValidators, + seen_aggregators: SeenValidators, attestation_pool: AttestationPool, /// Highest finalized slot PM has announced as a sync target — bounds the @@ -220,7 +221,8 @@ impl BeaconStateTile { state: owner, fork_choice: ForkChoice::default(), shuffling_cache: ShufflingCache::with_capacity(val_cap), - seen_attesters: SeenAttesters::new(val_cap), + seen_attesters: SeenValidators::new(val_cap), + seen_aggregators: SeenValidators::new(val_cap), attestation_pool: AttestationPool::new(), last_applied: anchor, last_applied_block_root: [0u8; 32], @@ -1954,6 +1956,30 @@ mod tests { assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); } + /// Spec [IGNORE]: at most one aggregate per (aggregator, target epoch) — + /// a byte-identical resend dies on the seen probe. + #[test] + fn agg_repeat_aggregator_epoch_ignored() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let buf = build_agg_for_vi0(&tile); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); + } + + /// A rejected aggregate must not mark the aggregator seen, or a forged + /// message would censor the aggregator's real aggregate for the epoch. + #[test] + fn agg_failed_validation_does_not_mark_aggregator() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let buf = build_agg_for_vi0(&tile); + let mut forged = buf.clone(); + forged[50] ^= 0xFF; // outer signature = buf[4..100) + assert_eq!(tile.handle_aggregate_and_proof(&forged), Feedback::Reject(None)); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); + } + /// First committee (skipping the wall slot) holding two members whose /// registry keys differ (vi % 3), so the aggregate is genuinely multi-key. fn find_committee_with_two_signers(tile: &BeaconStateTile) -> (Slot, usize, u32, u32) { @@ -1972,36 +1998,42 @@ mod tests { panic!("two distinct-key members in some committee") } + /// Sign `vi`'s single attestation for the anchor at `(slot, ci)`, feed it + /// through `handle_attestation`, and return the grown pooled aggregate. + fn pool_single_then_aggregate( + tile: &mut BeaconStateTile, + vi: u32, + slot: Slot, + ci: usize, + ) -> Vec { + let imm = seed_immutable(tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + vi as usize % 3, + vi as u64, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + let data_root = + ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + let subnet = expected_subnet(tile, slot, ci); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).expect("pooled aggregate") + } + #[test] fn pool_aggregate_accepted_by_aggregate_and_proof_path() { let mut tile = make_tile_at_wall_slot(31); seed_tile_with_keys(&mut tile, 128, 0); let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let single = |vi: u32| { - test_signing::sign_single_attestation( - vi as usize % 3, - vi as u64, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ) - }; - let a = single(vi_a); - let data_root = ssz_hash::hash_attestation_data(SingleAttestationView::data(&a).as_bytes()); - assert_eq!(tile.handle_attestation(&a, subnet), Feedback::Accept(None)); - assert_eq!(tile.handle_attestation(&single(vi_b), subnet), Feedback::Accept(None)); - - let aggregate = tile - .attestation_pool - .aggregate_ssz(slot, ci as u64, data_root) - .expect("pooled aggregate"); + pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + let aggregate = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); // Committee of 4 < 16 ⇒ any member trivially passes is_aggregator. let wrapped = test_signing::wrap_aggregate_and_proof( @@ -2016,6 +2048,48 @@ mod tests { assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Accept(None)); } + /// First-seen keys on (aggregator, target epoch), not message bytes: a + /// second, different-but-valid aggregate from the same aggregator is + /// still ignored. + #[test] + fn agg_repeat_keys_on_aggregator_not_bytes() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + let wrapped = + test_signing::wrap_aggregate_and_proof(vi_a as usize % 3, vi_a as u64, &agg_one, &imm); + assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Accept(None)); + + // A second participant grows the pooled aggregate: different bytes, + // fully valid, same (aggregator, target epoch). + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_ne!(agg_two, agg_one); + let wrapped = + test_signing::wrap_aggregate_and_proof(vi_a as usize % 3, vi_a as u64, &agg_two, &imm); + assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Ignore); + } + + /// The first-seen rule is per aggregator, not per attestation data: the + /// same aggregate wrapped by two distinct committee members both accept. + #[test] + fn agg_distinct_aggregators_same_data_both_accept() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let aggregate = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + // Committee of 4 < 16 ⇒ any member trivially passes is_aggregator. + let wrap = |vi: u32| { + test_signing::wrap_aggregate_and_proof(vi as usize % 3, vi as u64, &aggregate, &imm) + }; + assert_eq!(tile.handle_aggregate_and_proof(&wrap(vi_a)), Feedback::Accept(None)); + assert_eq!(tile.handle_aggregate_and_proof(&wrap(vi_b)), Feedback::Accept(None)); + } + // ── finalization (deposit append lands on the delta, not the base) ── /// A `PendingDeposit` for a brand-new validator (spec key 0), signed under diff --git a/crates/beacon_state/tile/src/tile/gossip.rs b/crates/beacon_state/tile/src/tile/gossip.rs index 7869e92e..7bf9d3f5 100644 --- a/crates/beacon_state/tile/src/tile/gossip.rs +++ b/crates/beacon_state/tile/src/tile/gossip.rs @@ -196,6 +196,12 @@ impl BeaconStateTile { { return Feedback::Ignore; } + + self.seen_aggregators.rotate_to(wall / SLOTS_PER_EPOCH); + if self.seen_aggregators.contains(parsed.target_epoch, parsed.aggregator_index) { + return Feedback::Ignore; + } + if parsed.committee_bits.count_ones() != 1 { return Feedback::Reject(None); } @@ -256,7 +262,11 @@ impl BeaconStateTile { // Record the votes (re-validates target/ancestry + slot+1 deferral and // derives the committee again — cheap next to the verify above). The // inner attestation is the aggregate field. - self.apply_attestation(parsed.aggregate_bytes) + let feedback = self.apply_attestation(parsed.aggregate_bytes); + if let Feedback::Accept(_) = feedback { + self.seen_aggregators.mark(parsed.target_epoch, parsed.aggregator_index); + } + feedback } pub(super) fn validate_execution_payload_envelope(&self, ssz: &[u8]) -> EnvelopeCheck { diff --git a/crates/beacon_state/tile/src/tile/seen_attesters.rs b/crates/beacon_state/tile/src/tile/seen_validators.rs similarity index 89% rename from crates/beacon_state/tile/src/tile/seen_attesters.rs rename to crates/beacon_state/tile/src/tile/seen_validators.rs index 70c328b0..b3bf8d2f 100644 --- a/crates/beacon_state/tile/src/tile/seen_attesters.rs +++ b/crates/beacon_state/tile/src/tile/seen_validators.rs @@ -2,12 +2,12 @@ use silver_beacon_state_data::Epoch; /// Lanes are indexed by epoch parity, so the {wall, wall-1} window maps each /// epoch to a stable lane and rotation is a re-arm of whichever lane expired. -pub(super) struct SeenAttesters { +pub(super) struct SeenValidators { epochs: [Epoch; 2], bits: [Vec; 2], } -impl SeenAttesters { +impl SeenValidators { pub(super) fn new(validator_capacity: usize) -> Self { let words = validator_capacity.div_ceil(64); // Sentinels above any wall epoch, parities matching their lanes. @@ -50,14 +50,14 @@ mod tests { #[test] fn fresh_lanes_contain_nothing() { - let seen = SeenAttesters::new(128); + let seen = SeenValidators::new(128); assert!(!seen.contains(0, 0)); assert!(!seen.contains(1, 127)); } #[test] fn mark_then_contains_within_window() { - let mut seen = SeenAttesters::new(128); + let mut seen = SeenValidators::new(128); seen.rotate_to(1); seen.mark(0, 7); seen.mark(1, 7); @@ -71,7 +71,7 @@ mod tests { #[test] fn rotation_clears_only_the_expired_lane() { - let mut seen = SeenAttesters::new(128); + let mut seen = SeenValidators::new(128); seen.rotate_to(0); seen.mark(0, 3); @@ -80,7 +80,7 @@ mod tests { assert!(seen.contains(0, 3)); // Window {1, 2}: epoch 0's lane is re-armed for epoch 2, so the - // attester becomes markable again. + // validator becomes markable again. seen.rotate_to(2); assert!(!seen.contains(0, 3)); assert!(!seen.contains(2, 3)); @@ -90,7 +90,7 @@ mod tests { #[test] fn out_of_window_epochs_read_empty() { - let mut seen = SeenAttesters::new(128); + let mut seen = SeenValidators::new(128); seen.rotate_to(5); seen.mark(5, 9); seen.mark(4, 9); @@ -102,7 +102,7 @@ mod tests { #[test] fn rotation_within_an_epoch_keeps_marks() { - let mut seen = SeenAttesters::new(128); + let mut seen = SeenValidators::new(128); seen.rotate_to(4); seen.mark(4, 11); seen.rotate_to(4); @@ -111,7 +111,7 @@ mod tests { #[test] fn marks_grow_past_construction_capacity() { - let mut seen = SeenAttesters::new(0); + let mut seen = SeenValidators::new(0); seen.rotate_to(0); assert!(!seen.contains(0, 200)); seen.mark(0, 200); From 63724552a5c54af63163b9135c65c473bbc48218 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 13 Aug 2026 11:38:14 +0100 Subject: [PATCH 09/13] [CL-111] Shed redundant aggregates before signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's non-strict-superset IGNORE now gates the aggregate topic: a message whose aggregation_bits are covered by one already-verified aggregate for the same (slot, committee, data root) dies on a byte compare before the ~1 ms three-signature batch verify — the common case on a topic where 16 aggregators per committee publish near-identical views. Bits inside the union of seen patterns but no single one still verify and relay (union coverage must not gate forwarding, per mesh scoring) and skip only the redundant local vote fold. The verified data root now feeds the signature check instead of being recomputed, and a second data root appearing for one (slot, committee) logs the late-block/split-view diagnostic. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/tile.rs | 163 ++++++++-- crates/beacon_state/tile/src/tile/gossip.rs | 43 ++- .../tile/src/tile/seen_aggregates.rs | 284 ++++++++++++++++++ 3 files changed, 460 insertions(+), 30 deletions(-) create mode 100644 crates/beacon_state/tile/src/tile/seen_aggregates.rs diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index ba97070c..a259c85a 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -24,7 +24,8 @@ use crate::{ merkle, ssz_hash, stf, tile::{ attestation_pool::AttestationPool, orphan_pool::PendingBlock, - seen_validators::SeenValidators, shuffling_cache::ShufflingCache, + seen_aggregates::SeenAggregates, seen_validators::SeenValidators, + shuffling_cache::ShufflingCache, }, weak_subjectivity::{weak_subjectivity_period_fulu, weak_subjectivity_period_gloas}, }; @@ -35,6 +36,7 @@ mod finalize; mod fork_choice; mod gossip; mod orphan_pool; +mod seen_aggregates; mod seen_validators; mod shuffling_cache; @@ -122,6 +124,7 @@ pub struct BeaconStateTile { shuffling_cache: Box, seen_attesters: SeenValidators, seen_aggregators: SeenValidators, + seen_aggregates: SeenAggregates, attestation_pool: AttestationPool, /// Highest finalized slot PM has announced as a sync target — bounds the @@ -223,6 +226,7 @@ impl BeaconStateTile { shuffling_cache: ShufflingCache::with_capacity(val_cap), seen_attesters: SeenValidators::new(val_cap), seen_aggregators: SeenValidators::new(val_cap), + seen_aggregates: SeenAggregates::new(), attestation_pool: AttestationPool::new(), last_applied: anchor, last_applied_block_root: [0u8; 32], @@ -522,7 +526,9 @@ impl BeaconStateTile { fn slot_tick(&mut self, slot: Slot) -> bool { let advanced = self.on_slot_start(slot); self.fork_choice_tick(); - self.attestation_pool.prune_before(slot.saturating_sub(1)); + let floor = slot.saturating_sub(1); + self.attestation_pool.prune_before(floor); + self.seen_aggregates.prune_before(floor); advanced } @@ -1998,6 +2004,20 @@ mod tests { panic!("two distinct-key members in some committee") } + fn committee_of(tile: &BeaconStateTile, slot: Slot, ci: usize) -> Vec { + let shuffled = tile + .shuffling_cache + .shuffled_by_epoch(slot / SLOTS_PER_EPOCH) + .expect("shuffling for epoch"); + stf::EpochShuffling::new(shuffled, tile.head_validator_count()).committee(slot, ci).to_vec() + } + + /// Wrap an inner aggregate with `vi` as aggregator (registry keys cycle + /// `vi % 3`). + fn wrap_by(imm: &Immutable, vi: u32, aggregate: &[u8]) -> Vec { + test_signing::wrap_aggregate_and_proof(vi as usize % 3, vi as u64, aggregate, imm) + } + /// Sign `vi`'s single attestation for the anchor at `(slot, ci)`, feed it /// through `handle_attestation`, and return the grown pooled aggregate. fn pool_single_then_aggregate( @@ -2036,12 +2056,7 @@ mod tests { let aggregate = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); // Committee of 4 < 16 ⇒ any member trivially passes is_aggregator. - let wrapped = test_signing::wrap_aggregate_and_proof( - vi_a as usize % 3, - vi_a as u64, - &aggregate, - &imm, - ); + let wrapped = wrap_by(&imm, vi_a, &aggregate); // Accept requires verify_aggregate_and_proof_sigs: selection proof, // outer signature, and the pooled aggregate signature against the two // participants' aggregated registry pubkeys. @@ -2059,21 +2074,24 @@ mod tests { let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - let wrapped = - test_signing::wrap_aggregate_and_proof(vi_a as usize % 3, vi_a as u64, &agg_one, &imm); - assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Accept(None)); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_one)), + Feedback::Accept(None) + ); // A second participant grows the pooled aggregate: different bytes, // fully valid, same (aggregator, target epoch). let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); assert_ne!(agg_two, agg_one); - let wrapped = - test_signing::wrap_aggregate_and_proof(vi_a as usize % 3, vi_a as u64, &agg_two, &imm); - assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Ignore); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), + Feedback::Ignore + ); } - /// The first-seen rule is per aggregator, not per attestation data: the - /// same aggregate wrapped by two distinct committee members both accept. + /// Neither admission rule keys on the attestation data alone: aggregates + /// over the same data from two distinct aggregators both accept, provided + /// the second grows bit coverage (equal bits die on the superset rule). #[test] fn agg_distinct_aggregators_same_data_both_accept() { let mut tile = make_tile_at_wall_slot(31); @@ -2081,13 +2099,112 @@ mod tests { let imm = seed_immutable(&tile); let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - let aggregate = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - // Committee of 4 < 16 ⇒ any member trivially passes is_aggregator. - let wrap = |vi: u32| { - test_signing::wrap_aggregate_and_proof(vi as usize % 3, vi as u64, &aggregate, &imm) - }; - assert_eq!(tile.handle_aggregate_and_proof(&wrap(vi_a)), Feedback::Accept(None)); - assert_eq!(tile.handle_aggregate_and_proof(&wrap(vi_b)), Feedback::Accept(None)); + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_one)), + Feedback::Accept(None) + ); + + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_two)), + Feedback::Accept(None) + ); + } + + /// Spec [IGNORE]: bits ⊆ an already-seen valid aggregate's — equal or + /// strictly smaller — die on the coverage probe regardless of who + /// aggregated them. + #[test] + fn agg_subset_from_other_aggregator_ignored() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), + Feedback::Accept(None) + ); + + // Both from an aggregator the epoch has not seen, so only the + // coverage rule can be what ignores them. + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_two)), + Feedback::Ignore + ); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_one)), + Feedback::Ignore + ); + } + + /// The superset gate fires before signature verification: a covered + /// message with a corrupted outer signature probes Ignore instead of + /// reaching the Reject the signature would earn. Skipping that ~1 ms + /// batch verify is the point of the coverage rule. + #[test] + fn agg_superset_gate_precedes_signature_verify() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), + Feedback::Accept(None) + ); + + let mut forged = wrap_by(&imm, vi_b, &agg_one); + forged[50] ^= 0xFF; // outer signature = buf[4..100) + assert_eq!(tile.handle_aggregate_and_proof(&forged), Feedback::Ignore); + } + + /// Union-covered bits (inside the OR of seen patterns, ⊆ none singly) + /// must still verify and relay: union coverage sheds only the local vote + /// fold, never forwarding. + #[test] + fn agg_union_covered_still_relays() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + let committee = committee_of(&tile, slot, ci); + let vi_c = *committee.iter().find(|&&v| v != vi_a && v != vi_b).expect("committee of 4"); + let pos_b = committee.iter().position(|&v| v == vi_b).unwrap(); + + // {a} from aggregator a, then {b} alone from aggregator b: disjoint + // patterns whose union is {a, b}. + let agg_a = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_a)), + Feedback::Accept(None) + ); + let agg_b_only = test_signing::sign_aggregate_and_proof( + vi_b as usize % 3, + vi_b as u64, + slot, + slot / SLOTS_PER_EPOCH, + bbr, + bbr, + ci, + pos_b, + committee.len(), + &imm, + ); + assert_eq!(tile.handle_aggregate_and_proof(&agg_b_only), Feedback::Accept(None)); + + // {a, b} from a third aggregator: within the union, inside neither. + let agg_ab = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_c, &agg_ab)), + Feedback::Accept(None) + ); } // ── finalization (deposit append lands on the delta, not the base) ── diff --git a/crates/beacon_state/tile/src/tile/gossip.rs b/crates/beacon_state/tile/src/tile/gossip.rs index 7bf9d3f5..ba815810 100644 --- a/crates/beacon_state/tile/src/tile/gossip.rs +++ b/crates/beacon_state/tile/src/tile/gossip.rs @@ -19,6 +19,7 @@ use super::{ ATTESTATION_PROPAGATION_SLOT_RANGE, BY_ROOT_REQUEST_ID, BeaconStateTile, Feedback, Producers, attestation_pool::InsertOutcome, orphan_pool::{PendingBlock, has_room}, + seen_aggregates::Coverage, }; use crate::{bls, merkle, ssz_hash, stf, validate}; @@ -207,6 +208,17 @@ impl BeaconStateTile { } let committee_index = parsed.committee_bits.trailing_zeros() as usize; + let data_root = ssz_hash::hash_attestation_data(parsed.agg_data.as_bytes()); + let coverage = self.seen_aggregates.coverage( + parsed.agg_slot, + committee_index as u64, + data_root, + parsed.aggregation_bits, + ); + if coverage == Coverage::BySuperset { + return Feedback::Ignore; + } + if let Err(f) = self.validate_attestation_target(parsed.agg_data) { return f; } @@ -254,16 +266,33 @@ impl BeaconStateTile { return Feedback::Reject(None); } - if !Self::verify_aggregate_and_proof_sigs(&view, &parsed, &committees, &mut self.sig_batch) - { + if !Self::verify_aggregate_and_proof_sigs( + &view, + &parsed, + &committees, + data_root, + &mut self.sig_batch, + ) { return Feedback::Reject(None); } - // Record the votes (re-validates target/ancestry + slot+1 deferral and - // derives the committee again — cheap next to the verify above). The - // inner attestation is the aggregate field. - let feedback = self.apply_attestation(parsed.aggregate_bytes); + let feedback = if coverage == Coverage::ByUnion { + // Every vote it carries is already folded; the verified message + // still relays — union coverage must never gate forwarding. + Feedback::Accept(None) + } else { + // Record the votes (re-validates target/ancestry + slot+1 + // deferral and derives the committee again — cheap next to the + // verify above). The inner attestation is the aggregate field. + self.apply_attestation(parsed.aggregate_bytes) + }; if let Feedback::Accept(_) = feedback { + self.seen_aggregates.record( + parsed.agg_slot, + committee_index as u64, + data_root, + parsed.aggregation_bits, + ); self.seen_aggregators.mark(parsed.target_epoch, parsed.aggregator_index); } feedback @@ -445,6 +474,7 @@ impl BeaconStateTile { view: &StateReadView, parsed: &ParsedAggregateAndProof<'_>, committees: &stf::AttestedCommittees<'_>, + data_root: B256, sig_batch: &mut bls::SigBatch, ) -> bool { let fv = view.epoch.fork_version_at(parsed.target_epoch); @@ -467,7 +497,6 @@ impl BeaconStateTile { bls::compute_signing_root(&agg_proof_root, &domain(bls::DOMAIN_AGGREGATE_AND_PROOF)); // (3) inner aggregate signature over AttestationData. - let data_root = ssz_hash::hash_attestation_data(parsed.agg_data.as_bytes()); let sr_att = bls::compute_signing_root(&data_root, &domain(bls::DOMAIN_BEACON_ATTESTER)); sig_batch.clear(); diff --git a/crates/beacon_state/tile/src/tile/seen_aggregates.rs b/crates/beacon_state/tile/src/tile/seen_aggregates.rs new file mode 100644 index 00000000..ea1725ec --- /dev/null +++ b/crates/beacon_state/tile/src/tile/seen_aggregates.rs @@ -0,0 +1,284 @@ +use rustc_hash::FxHashMap; +use silver_beacon_state_data::{B256, Slot}; +use silver_common::{metrics::timed, ssz_view::MAX_COMMITTEES_PER_SLOT}; + +/// Same derivation as the pool cap: two retained slots at +/// ≤ MAX_COMMITTEES_PER_SLOT committees each, ×4 headroom for competing +/// data_root variants. +const MAX_ENTRIES: usize = 4 * 2 * MAX_COMMITTEES_PER_SLOT; + +/// Honest traffic per key is ≤ TARGET_AGGREGATORS_PER_COMMITTEE (16) heavily +/// overlapping patterns whose maximal antichain stays at 1-3. When full, new +/// patterns stop being stored; probes keep working and the union still +/// accumulates. +const MAX_PATTERNS: usize = 8; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct CoverageKey { + slot: Slot, + committee_index: u64, + data_root: B256, +} + +/// Patterns are raw SSZ bitlist bytes, terminator included: recorded +/// aggregates for one key all validated against the same committee, so their +/// terminators align and byte-wise `incoming & !stored == 0` subset tests are +/// exact. A probe whose byte length differs is simply never covered. +struct CommitteeCoverage { + /// OR of every recorded pattern. + union: Vec, + /// Maximal antichain of recorded patterns (no member ⊆ another). + antichain: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Coverage { + /// ⊆ one recorded pattern — the spec's non-strict-superset IGNORE. + BySuperset, + /// ⊆ the union of recorded patterns but no single one: every vote it + /// carries is already folded, yet it is not the spec-ignorable case. + ByUnion, + New, +} + +/// Bit-coverage ledger of valid aggregates per (slot, committee, data_root). +/// +/// Retention is the pool's two-slot window, not the full 33-slot propagation +/// range: a redundant aggregate older than the window can only reach the +/// verify stage from an aggregator index unseen this target epoch — the +/// first-seen (epoch, aggregator) rule spans the whole epoch — so deeper +/// retention would grow the map ~16× without shedding more verifies. +/// +/// The spec also counts aggregates seen within verified blocks toward its +/// superset rule; block processing does not record here — missing that only +/// costs an occasional redundant verify. +pub(super) struct SeenAggregates { + entries: FxHashMap, + floor: Slot, +} + +impl SeenAggregates { + pub(super) fn new() -> Self { + Self { + entries: FxHashMap::with_capacity_and_hasher(MAX_ENTRIES, Default::default()), + floor: 0, + } + } + + pub(super) fn coverage( + &self, + slot: Slot, + committee_index: u64, + data_root: B256, + bits: &[u8], + ) -> Coverage { + let Some(entry) = self.entries.get(&CoverageKey { slot, committee_index, data_root }) + else { + return Coverage::New; + }; + // Not ⊆ union ⇒ not ⊆ any single pattern, so the one union pass + // short-circuits the common new-coverage case before the antichain + // scan. + if bits.len() != entry.union.len() || !is_subset(bits, &entry.union) { + return Coverage::New; + } + if entry.antichain.iter().any(|stored| is_subset(bits, stored)) { + return Coverage::BySuperset; + } + Coverage::ByUnion + } + + /// Record a fully verified aggregate's bits. + pub(super) fn record( + &mut self, + slot: Slot, + committee_index: u64, + data_root: B256, + bits: &[u8], + ) { + if slot < self.floor { + return; + } + let key = CoverageKey { slot, committee_index, data_root }; + if let Some(entry) = self.entries.get_mut(&key) { + entry.add(bits); + return; + } + if self.entries.len() >= MAX_ENTRIES { + tracing::debug!(slot, committee = committee_index, "seen-aggregates full"); + return; + } + if self.entries.keys().any(|k| k.slot == slot && k.committee_index == committee_index) { + // Late-block / split-view signal: one committee attesting two + // different AttestationData in the same slot. + tracing::debug!(slot, committee = committee_index, "second data_root for committee"); + } + self.entries.insert(key, CommitteeCoverage::new(bits)); + } + + #[timed] + pub(super) fn prune_before(&mut self, floor: Slot) { + self.floor = floor; + self.entries.retain(|key, _| key.slot >= floor); + } +} + +impl CommitteeCoverage { + fn new(bits: &[u8]) -> Self { + Self { union: bits.to_vec(), antichain: vec![bits.to_vec()] } + } + + fn add(&mut self, bits: &[u8]) { + debug_assert_eq!(bits.len(), self.union.len()); + if bits.len() != self.union.len() || + self.antichain.iter().any(|stored| is_subset(bits, stored)) + { + return; + } + self.antichain.retain(|stored| !is_subset(stored, bits)); + if self.antichain.len() < MAX_PATTERNS { + self.antichain.push(bits.to_vec()); + } + for (u, b) in self.union.iter_mut().zip(bits) { + *u |= b; + } + } +} + +fn is_subset(inner: &[u8], outer: &[u8]) -> bool { + debug_assert_eq!(inner.len(), outer.len()); + inner.iter().zip(outer).all(|(i, o)| i & !o == 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SLOT: Slot = 3; + const ROOT: B256 = [0xAB; 32]; + + /// One-byte pattern for a 4-member committee: participant bits 0-3, + /// terminator at bit 4. + fn bits4(participants: u8) -> Vec { + debug_assert!(participants < 0b1_0000); + vec![0b0001_0000 | participants] + } + + fn probe(seen: &SeenAggregates, bits: &[u8]) -> Coverage { + seen.coverage(SLOT, 0, ROOT, bits) + } + + #[test] + fn unknown_key_probes_new() { + let mut seen = SeenAggregates::new(); + assert_eq!(probe(&seen, &bits4(0b0110)), Coverage::New); + seen.record(SLOT, 0, ROOT, &bits4(0b0110)); + assert_eq!(seen.coverage(SLOT, 1, ROOT, &bits4(0b0110)), Coverage::New); + assert_eq!(seen.coverage(SLOT + 1, 0, ROOT, &bits4(0b0110)), Coverage::New); + assert_eq!(seen.coverage(SLOT, 0, [0xCD; 32], &bits4(0b0110)), Coverage::New); + } + + #[test] + fn equal_and_subset_bits_are_superset_covered() { + let mut seen = SeenAggregates::new(); + seen.record(SLOT, 0, ROOT, &bits4(0b0110)); + assert_eq!(probe(&seen, &bits4(0b0110)), Coverage::BySuperset); + assert_eq!(probe(&seen, &bits4(0b0100)), Coverage::BySuperset); + assert_eq!(probe(&seen, &bits4(0b0010)), Coverage::BySuperset); + } + + #[test] + fn strict_superset_is_new_until_recorded() { + let mut seen = SeenAggregates::new(); + seen.record(SLOT, 0, ROOT, &bits4(0b0010)); + assert_eq!(probe(&seen, &bits4(0b0110)), Coverage::New); + seen.record(SLOT, 0, ROOT, &bits4(0b0110)); + assert_eq!(probe(&seen, &bits4(0b0110)), Coverage::BySuperset); + assert_eq!(probe(&seen, &bits4(0b0100)), Coverage::BySuperset); + } + + #[test] + fn union_covered_without_single_superset() { + let mut seen = SeenAggregates::new(); + seen.record(SLOT, 0, ROOT, &bits4(0b0011)); + seen.record(SLOT, 0, ROOT, &bits4(0b1100)); + assert_eq!(probe(&seen, &bits4(0b0101)), Coverage::ByUnion); + assert_eq!(probe(&seen, &bits4(0b1111)), Coverage::ByUnion); + assert_eq!(probe(&seen, &bits4(0b0011)), Coverage::BySuperset); + } + + /// Two-byte patterns for an 8-member committee (terminator = byte 1 + /// bit 0): subset tests must hold across the byte boundary. + #[test] + fn byte_boundary_patterns() { + let mut seen = SeenAggregates::new(); + seen.record(SLOT, 0, ROOT, &[0b1000_0001, 0b01]); + assert_eq!(probe(&seen, &[0b1000_0000, 0b01]), Coverage::BySuperset); + assert_eq!(probe(&seen, &[0b0000_0001, 0b01]), Coverage::BySuperset); + assert_eq!(probe(&seen, &[0b1000_0010, 0b01]), Coverage::New); + // A different byte length is never covered. + assert_eq!(probe(&seen, &bits4(0b0001)), Coverage::New); + } + + /// Recording a pattern that covers stored ones must evict them, or the + /// antichain silts up: fill the cap with 8 disjoint singletons, then + /// record their union — it only fits (and probes BySuperset) if all 8 + /// were evicted. + #[test] + fn superset_record_evicts_covered_patterns() { + // 9-member committee: participants 0-7 in byte 0, bit 8 in byte 1, + // terminator at byte 1 bit 1. + let bits9 = |b0: u8, b1: u8| vec![b0, b1 | 0b10]; + let mut seen = SeenAggregates::new(); + for i in 0..8 { + seen.record(SLOT, 0, ROOT, &bits9(1 << i, 0)); + } + seen.record(SLOT, 0, ROOT, &bits9(0xFF, 0)); + assert_eq!(seen.coverage(SLOT, 0, ROOT, &bits9(0xFF, 0)), Coverage::BySuperset); + assert_eq!(seen.coverage(SLOT, 0, ROOT, &bits9(0b1010, 0)), Coverage::BySuperset); + } + + /// At the pattern cap, new coverage stops being stored as a pattern + /// (never BySuperset) but still accumulates in the union. + #[test] + fn pattern_cap_stops_storing_but_union_grows() { + let bits9 = |b0: u8, b1: u8| vec![b0, b1 | 0b10]; + let mut seen = SeenAggregates::new(); + for i in 0..8 { + seen.record(SLOT, 0, ROOT, &bits9(1 << i, 0)); + } + seen.record(SLOT, 0, ROOT, &bits9(0, 1)); + // ByUnion pins both halves: not BySuperset (the pattern was not + // stored) and not New (the union still absorbed it). + assert_eq!(seen.coverage(SLOT, 0, ROOT, &bits9(0, 1)), Coverage::ByUnion); + assert_eq!(seen.coverage(SLOT, 0, ROOT, &bits9(1, 0)), Coverage::BySuperset); + } + + #[test] + fn entry_cap_stops_new_keys() { + let mut seen = SeenAggregates::new(); + for i in 0..MAX_ENTRIES { + let mut root = [0u8; 32]; + root[0..8].copy_from_slice(&(i as u64).to_le_bytes()); + seen.record(SLOT, 0, root, &bits4(0b0001)); + } + seen.record(SLOT, 0, ROOT, &bits4(0b0001)); + assert_eq!(probe(&seen, &bits4(0b0001)), Coverage::New); + // Existing keys are intact. + assert_eq!(seen.coverage(SLOT, 0, [0u8; 32], &bits4(0b0001)), Coverage::BySuperset); + } + + #[test] + fn prune_drops_expired_slots_and_floors_recording() { + let mut seen = SeenAggregates::new(); + seen.record(SLOT, 0, ROOT, &bits4(0b0001)); + seen.record(SLOT + 1, 0, ROOT, &bits4(0b0001)); + + seen.prune_before(SLOT + 1); + + assert_eq!(probe(&seen, &bits4(0b0001)), Coverage::New); + assert_eq!(seen.coverage(SLOT + 1, 0, ROOT, &bits4(0b0001)), Coverage::BySuperset); + seen.record(SLOT, 0, ROOT, &bits4(0b0001)); + assert_eq!(probe(&seen, &bits4(0b0001)), Coverage::New); + } +} From d2aea350421c531ae6c3359501223d4e2c49dfc7 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 13 Aug 2026 12:02:53 +0100 Subject: [PATCH 10/13] Count admission-structure saturation in shmem counters AttestationPoolFull and SeenAggregatesFull surface capacity pressure on the two bounded gossip-admission structures in surfer, where a debug log alone cannot show rates. Registers the tile's first counter enum in surfer's schema table. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/counters.rs | 14 ++++++++++++++ crates/beacon_state/tile/src/lib.rs | 2 ++ crates/beacon_state/tile/src/tile/gossip.rs | 3 ++- .../beacon_state/tile/src/tile/seen_aggregates.rs | 3 +++ crates/surfer/Cargo.toml | 1 + crates/surfer/src/schema.rs | 1 + 6 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 crates/beacon_state/tile/src/counters.rs diff --git a/crates/beacon_state/tile/src/counters.rs b/crates/beacon_state/tile/src/counters.rs new file mode 100644 index 00000000..203a9821 --- /dev/null +++ b/crates/beacon_state/tile/src/counters.rs @@ -0,0 +1,14 @@ +//! Per-tile counters exposed via `silver_common`'s shmem-mapped +//! atomic-counter primitive. +//! +//! Counters are interpreted by position — append before `_Count`, do +//! not reorder. + +silver_common::declare_counters! { + pub BeaconStateCounters => "beacon_state" { + // gossip-admission structures at capacity (attestations still + // accepted and relayed; aggregation/shedding coverage degrades) + AttestationPoolFull, + SeenAggregatesFull, + } +} diff --git a/crates/beacon_state/tile/src/lib.rs b/crates/beacon_state/tile/src/lib.rs index 471e0a72..0d6d0368 100644 --- a/crates/beacon_state/tile/src/lib.rs +++ b/crates/beacon_state/tile/src/lib.rs @@ -1,6 +1,7 @@ #![allow(clippy::result_large_err)] pub mod bls; +pub mod counters; pub mod error; mod fork_choice; pub mod shuffling; @@ -16,6 +17,7 @@ pub(crate) mod test_signing; #[cfg(test)] pub(crate) mod test_state; +pub use counters::BeaconStateCounters; pub use error::{Error, PrecheckError, Result}; // Fork-choice store types, exposed only for the EF vector harnesses' read // access via `BeaconStateTile::ef_fork_choice`. diff --git a/crates/beacon_state/tile/src/tile/gossip.rs b/crates/beacon_state/tile/src/tile/gossip.rs index ba815810..7b6aad50 100644 --- a/crates/beacon_state/tile/src/tile/gossip.rs +++ b/crates/beacon_state/tile/src/tile/gossip.rs @@ -21,7 +21,7 @@ use super::{ orphan_pool::{PendingBlock, has_room}, seen_aggregates::Coverage, }; -use crate::{bls, merkle, ssz_hash, stf, validate}; +use crate::{bls, counters::BeaconStateCounters, merkle, ssz_hash, stf, validate}; pub(super) enum EnvelopeCheck { Ready { block_root: B256, state_id: StateId }, @@ -119,6 +119,7 @@ impl BeaconStateTile { ); debug_assert!(outcome != InsertOutcome::Inconsistent); if outcome == InsertOutcome::Full { + BeaconStateCounters::AttestationPoolFull.inc(); tracing::debug!(slot = att_slot, committee = committee_index, "attestation pool full"); } diff --git a/crates/beacon_state/tile/src/tile/seen_aggregates.rs b/crates/beacon_state/tile/src/tile/seen_aggregates.rs index ea1725ec..94a02fb7 100644 --- a/crates/beacon_state/tile/src/tile/seen_aggregates.rs +++ b/crates/beacon_state/tile/src/tile/seen_aggregates.rs @@ -2,6 +2,8 @@ use rustc_hash::FxHashMap; use silver_beacon_state_data::{B256, Slot}; use silver_common::{metrics::timed, ssz_view::MAX_COMMITTEES_PER_SLOT}; +use crate::counters::BeaconStateCounters; + /// Same derivation as the pool cap: two retained slots at /// ≤ MAX_COMMITTEES_PER_SLOT committees each, ×4 headroom for competing /// data_root variants. @@ -105,6 +107,7 @@ impl SeenAggregates { return; } if self.entries.len() >= MAX_ENTRIES { + BeaconStateCounters::SeenAggregatesFull.inc(); tracing::debug!(slot, committee = committee_index, "seen-aggregates full"); return; } diff --git a/crates/surfer/Cargo.toml b/crates/surfer/Cargo.toml index 7fe14c24..82f6f084 100644 --- a/crates/surfer/Cargo.toml +++ b/crates/surfer/Cargo.toml @@ -11,6 +11,7 @@ flux.workspace = true hdrhistogram.workspace = true libc.workspace = true ratatui.workspace = true +silver_beacon_state.workspace = true silver_beacon_state_data.workspace = true silver_columns.workspace = true silver_common.workspace = true diff --git a/crates/surfer/src/schema.rs b/crates/surfer/src/schema.rs index c3990fbe..2c2bc805 100644 --- a/crates/surfer/src/schema.rs +++ b/crates/surfer/src/schema.rs @@ -8,6 +8,7 @@ /// enums via `silver_common::declare_counters!`. pub fn lookup(file_name: &str) -> Option<&'static [&'static str]> { match file_name { + "beacon_state" => Some(silver_beacon_state::BeaconStateCounters::NAMES), "storage" => Some(silver_storage::StorageCounters::NAMES), "columns" => Some(silver_columns::DataColumnCounters::NAMES), "network" => Some(silver_network::NetworkCounters::NAMES), From be442b3e0b83366188ec0068fc4e47b8156d21d5 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 13 Aug 2026 14:11:16 +0100 Subject: [PATCH 11/13] Move BeaconStateTile unit tests to their own file Reviewer request: pure mechanical move of the inline test module to src/tile/tests.rs; module path and visibility unchanged. Assisted-by: Claude:claude-fable-5 --- crates/beacon_state/tile/src/tile.rs | 1725 +------------------- crates/beacon_state/tile/src/tile/tests.rs | 1679 +++++++++++++++++++ 2 files changed, 1680 insertions(+), 1724 deletions(-) create mode 100644 crates/beacon_state/tile/src/tile/tests.rs diff --git a/crates/beacon_state/tile/src/tile.rs b/crates/beacon_state/tile/src/tile.rs index a259c85a..997f116c 100644 --- a/crates/beacon_state/tile/src/tile.rs +++ b/crates/beacon_state/tile/src/tile.rs @@ -788,1727 +788,4 @@ pub(crate) fn get_blob_parameters( } #[cfg(test)] -mod tests { - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - use flux::timing::Nanos; - use silver_beacon_state_data::{ - BLSPubkey, BeaconBlockHeader, BeaconState, EPOCHS_PER_HISTORICAL_VECTOR, - EPOCHS_PER_SLASHINGS_VECTOR, EpochState, EpochStateFinalized, Immutable, - PROPOSER_LOOKAHEAD_SIZE, PendingDeposit, SlotStateId, ValSeed, Withdrawals, - }; - use silver_common::{ - GossipTopic, MessageId, P2pStreamId, StreamProtocol, TCache, TCacheProducer, TProducer, - ssz_view::{ - AttestationView, PROPOSER_SLASHING_SIZE, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, - SIGNED_VOLUNTARY_EXIT_SIZE, SignedAggregateAndProofView, SingleAttestationView, - }, - }; - - use super::*; - use crate::{ - fork_choice::{BlockImport, PayloadStatus}, - stf::AttestationVote, - test_signing, - }; - - const MAX_EFFECTIVE_BALANCE: u64 = 32_000_000_000; - const ANCHOR_ROOT: B256 = [0x01u8; 32]; - - fn make_tile() -> BeaconStateTile { - make_tile_at_wall_slot(1) - } - - /// Tile whose ticker reports `wall_slot` as the current slot. - fn make_tile_at_wall_slot(wall_slot: u64) -> BeaconStateTile { - make_tile_at_wall_slot_ws(wall_slot, true) - } - - fn make_tile_at_wall_slot_ws( - wall_slot: u64, - verify_weak_subjectivity: bool, - ) -> BeaconStateTile { - let secs_per_slot = 12u64; - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); - let genesis = now.saturating_sub(wall_slot * secs_per_slot + 1); - let ticker = SlotTicker::new(genesis, Duration::from_secs(12), Duration::from_secs(4)); - let gossip_p = TCache::producer("test_gossip", 1 << 20); - let event_p = TCache::producer("test_event", 1 << 20); - let engine_p = TCache::producer("test_engine", 1 << 20); - let replay_p = TCache::producer("test_replay", 1 << 20); - let gossip_c = gossip_p.cache_ref().random_access("test_gossip", true).unwrap(); - let rpc_c = event_p.cache_ref().random_access("test_event", true).unwrap(); - let engine_c = engine_p.cache_ref().random_access("test_engine", true).unwrap(); - let replay_c = replay_p.cache_ref().random_access("test_replay", true).unwrap(); - BeaconStateTile::new( - ticker, - Arc::new(SpecConfig::mainnet()), - &SyncingConfig::default(), - gossip_c, - rpc_c, - engine_c, - replay_c, - verify_weak_subjectivity, - BeaconState::empty_test(0), - ) - } - - /// Like `make_tile_at_wall_slot` but returns the gossip producer so tests - /// can write real block buffers the tile's consumer can read back. - fn make_tile_with_gossip(wall_slot: u64) -> (BeaconStateTile, TProducer) { - let secs_per_slot = 12u64; - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); - let genesis = now.saturating_sub(wall_slot * secs_per_slot + 1); - let ticker = SlotTicker::new(genesis, Duration::from_secs(12), Duration::from_secs(4)); - let gossip_p = TCache::producer("test_gossip_buf", 1 << 20); - let event_p = TCache::producer("test_event_buf", 1 << 20); - let engine_p = TCache::producer("test_engine", 1 << 20); - let replay_p = TCache::producer("test_replay_buf", 1 << 20); - let gossip_c = gossip_p.cache_ref().random_access("test_gossip_buf", true).unwrap(); - let rpc_c = event_p.cache_ref().random_access("test_event_buf", true).unwrap(); - let engine_c = engine_p.cache_ref().random_access("test_engine", true).unwrap(); - let replay_c = replay_p.cache_ref().random_access("test_replay_buf", true).unwrap(); - let tile = BeaconStateTile::new( - ticker, - Arc::new(SpecConfig::mainnet()), - &SyncingConfig::default(), - gossip_c, - rpc_c, - engine_c, - replay_c, - true, - BeaconState::empty_test(0), - ); - (tile, gossip_p) - } - - /// Publish a minimal block (slot at offset 100) into `producer` and wrap it - /// as a buffered gossip orphan whose slot the tile can read back. - fn gossip_pending(producer: &mut TProducer, slot: u64) -> PendingBlock { - let mut bytes = vec![0u8; 200]; - bytes[100..108].copy_from_slice(&slot.to_le_bytes()); - let mut r = producer.reserve(bytes.len(), true).expect("reserve"); - if let Ok(buf) = r.buffer() { - buf[..bytes.len()].copy_from_slice(&bytes); - } - r.increment_offset(bytes.len()); - let read = r.read(); - producer.publish_head(); - PendingBlock::Gossip(NewGossipMsg { - stream_id: P2pStreamId::new(0, 0, StreamProtocol::Unset, false), - topic: GossipTopic::BeaconBlock, - msg_hash: MessageId { id: [0u8; 20] }, - recv_ts: Nanos(0), - ssz: read, - protobuf: read, - }) - } - - fn placeholder_pubkey(i: usize) -> BLSPubkey { - let mut pk = [0u8; 48]; - pk[..4].copy_from_slice(&(i as u32).to_le_bytes()); - pk - } - - /// Epoch-tier base with zeroed rings and the given checkpoints seeded — - /// the harness analog of a decomposed anchor. - fn epoch_base_with(justified: Checkpoint, finalized: Checkpoint) -> EpochStateFinalized { - EpochStateFinalized::from_parts( - EpochState { - current_justified_checkpoint: justified, - finalized_checkpoint: finalized, - ..Default::default() - }, - vec![[0u8; 32]; EPOCHS_PER_HISTORICAL_VECTOR].into_boxed_slice(), - vec![0u64; EPOCHS_PER_SLASHINGS_VECTOR].into_boxed_slice(), - ) - } - - /// Build seed bases with `n` active validators (MAX effective balance, - /// activation epoch 0, exit FAR_FUTURE) at `start_slot`. With `real_keys`, - /// install spec BLS test pubkeys (+ BLS-prefix withdrawal creds) so - /// signature-checking handlers accept; otherwise collision-free - /// placeholder pubkeys. - fn build_seed_finalized(n: usize, real_keys: bool) -> (EpochStateFinalized, Vec) { - let cp_fin = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; - let seeds: Vec = (0..n) - .map(|i| { - let (pubkey, withdrawal_credentials) = if real_keys { - let sk_idx = i % test_signing::PRIVKEY_HEX.len(); - let pk_bytes = test_signing::pubkey_pk(sk_idx).to_bytes(); - // BLS-prefix creds: [0]=0x00, [1..]=hash(pk)[1..]. - let mut creds = Withdrawals(merkle::sha256(&pk_bytes)); - creds.0[0] = 0x00; - (pk_bytes, creds) - } else { - (placeholder_pubkey(i), Withdrawals::default()) - }; - ValSeed { - pubkey, - withdrawal_credentials, - effective_balance: MAX_EFFECTIVE_BALANCE, - balance: MAX_EFFECTIVE_BALANCE, - activation_epoch: 0, - ..Default::default() - } - }) - .collect(); - // The finalized slot lives in the slot-group base (seeded in - // `arm_tile` from `start_slot`); the validator registry rides its own - // group (seeded in `arm_tile` from `seeds`); the epoch tier rides its - // own group (built in `arm_tile` from this base). - (epoch_base_with(cp_fin, cp_fin), seeds) - } - - /// Install the seed bases as the tile's state, anchor an empty per-fork - /// delta seeded with the finalized slot scalars, and arm fork choice + the - /// start-epoch attester shuffling at the anchor. - fn arm_tile( - tile: &mut BeaconStateTile, - epoch_base: EpochStateFinalized, - seeds: &[ValSeed], - start_slot: Slot, - ) { - // Test-built state: epoch base from `epoch_base`, registry + balances - // column from `seeds`, slot base anchored at `start_slot`, the rest - // empty. - let mut bs = BeaconState::for_test(epoch_base, seeds, start_slot); - // Anchor each tier's fork at the base (the slot tier at `start_slot`); - // epoch/longtail stay lazy. Rolled before the owner wraps the state. - let anchor = bs.roll_fresh(); - let mut owner = BeaconStateOwner::new(bs); - owner.publish_state_id(anchor); - - tile.state = owner; - tile.shuffling_cache = ShufflingCache::with_capacity(seeds.len()); - tile.last_applied = anchor; - tile.last_applied_block_root = ANCHOR_ROOT; - tile.mode = Mode::Following; - - let cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; - tile.fork_choice = ForkChoice::init( - cp, - cp, - start_slot, - ANCHOR_ROOT, - [0u8; 32], - false, - anchor, - seeds.len(), - ); - - let view = tile.state.read_view(anchor); - tile.shuffling_cache.ensure_window(&view, start_slot / SLOTS_PER_EPOCH); - } - - fn seed_tile(tile: &mut BeaconStateTile, n: usize, start_slot: Slot) { - let (epoch_base, seeds) = build_seed_finalized(n, false); - arm_tile(tile, epoch_base, &seeds, start_slot); - } - - fn seed_tile_with_keys(tile: &mut BeaconStateTile, n: usize, start_slot: Slot) { - let (epoch_base, seeds) = build_seed_finalized(n, true); - arm_tile(tile, epoch_base, &seeds, start_slot); - } - - /// Immutable tier the signed-object builders sign against. `seed_*` leave - /// the base immutable all-default, so a default crate `Immutable` matches - /// the common one the handlers read, field-for-field (fork versions + gvr - /// all zero → identical signing domains). - fn seed_immutable(_tile: &BeaconStateTile) -> Immutable { - Immutable::default() - } - - #[test] - fn slot_advance_skip_multiple() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 10); - tile.on_slot_start(15); - assert_eq!(tile.head_state_slot(), 15); - } - - #[test] - fn slot_advance_noop_past_slot() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 10); - tile.on_slot_start(5); - assert_eq!(tile.head_state_slot(), 10); - } - - #[test] - fn slot_advance_crosses_epoch_boundary() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 30); - tile.on_slot_start(34); - assert_eq!(tile.head_state_slot(), 34); - // Crossing into epoch 1 allocated the head fork its own epoch delta. - assert!(tile.last_applied.epoch_idx.is_some()); - } - - /// Sustained non-finality: slot advances far past the rings' initial - /// capacity (slot tiers past `SLOTS_RING_N` twice over, the epoch tier - /// past `EPOCHS_RING_N`) must grow the rings instead of panicking on - /// wrap, with the head still resolving after every advance. - #[test] - fn slot_advance_grows_rings_under_non_finality() { - use silver_beacon_state_data::{SLOTS_PER_EPOCH, SLOTS_RING_N}; - - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let target = SLOTS_RING_N as u64 * 2 + 3 * SLOTS_PER_EPOCH; - for s in 1..=target { - tile.on_slot_start(s); - assert_eq!(tile.head_state_slot(), s); - } - } - - /// Regression: advancing the head over an empty epoch-boundary slot must - /// COW the epoch tier, not shift the parent's shared `proposer_lookahead` - /// in place. The parent stays a live fork-choice node the proposer - /// precheck reads; an in-place shift left its next-epoch slice one epoch - /// too far, rejecting valid boundary blocks. - #[test] - fn empty_slot_advance_preserves_parent_lookahead() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 31); // anchor at epoch-0's last slot - - // Give the anchor its own epoch entry with a recognizable lookahead — - // rolled, mutated through the held writer, committed (append-only). - // The anchor bundle is rebuilt with the new epoch id and re-installed - // as the head (the fork-choice anchor node keeps the lazy bundle — - // this test never reads it). - let anchor_epoch_idx = { - let mut g = tile.state.write(); - let mut w = g.epoch.roll_inheriting(tile.last_applied.epoch_idx); - let es = w.state_mut(); - for i in 0..PROPOSER_LOOKAHEAD_SIZE { - es.proposer_lookahead[i] = i as u64; - } - w.commit() - }; - tile.last_applied.epoch_idx = Some(anchor_epoch_idx); - let before = tile.state.state().epoch.view(anchor_epoch_idx).state().proposer_lookahead; - - // Advance across the epoch 0 -> 1 boundary on empty slots. - tile.on_slot_start(32); - assert_eq!(tile.head_state_slot(), 32); - - // Head forked onto a private epoch delta; the anchor's is untouched. - let head_epoch_idx = tile.last_applied.epoch_idx.unwrap(); - assert_ne!(head_epoch_idx, anchor_epoch_idx, "head must COW its epoch delta"); - let after = tile.state.state().epoch.view(anchor_epoch_idx).state().proposer_lookahead; - assert_eq!(before, after, "parent proposer_lookahead shifted in place"); - } - - #[test] - fn slot_advance_crosses_two_epoch_boundaries() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 30); - tile.on_slot_start(66); - assert_eq!(tile.head_state_slot(), 66); - } - - #[test] - fn block_unknown_parent_rejected() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 10); - - // Minimal SignedBeaconBlock: message at fixed offset 100 (4-byte - // offset + 96-byte signature), parent_root @ 116 set to an unknown - // root so precheck bails with ParentMissing before any state change. - let mut buf = vec![0u8; 200]; - buf[100..108].copy_from_slice(&11u64.to_le_bytes()); // slot - buf[108..116].copy_from_slice(&0u64.to_le_bytes()); // proposer_index - buf[116] = 0xFF; // parent_root[0] - - let head_before = tile.last_applied; - let nodes_before = tile.fork_choice.nodes.len(); - tile.apply_and_publish(&buf, true, false, |_block_root| {}); - - assert_eq!(tile.last_applied, head_before, "head must be unchanged"); - assert_eq!(tile.fork_choice.nodes.len(), nodes_before, "no node added"); - } - - // ── pending-block bounds ── - - #[test] - fn pending_admission_window_bounds() { - let mut tile = make_tile_at_wall_slot(50); - seed_tile(&mut tile, 4, 10); - let fin = tile.head_finalized_checkpoint().epoch * SLOTS_PER_EPOCH; - let tol = tile.pending_bounds.future_tolerance; - // At/below the finalized boundary: rejected. - assert!(!tile.within_pending_window(fin)); - // Above finalized and within the future tolerance: admitted. - assert!(tile.within_pending_window(fin + 1)); - assert!(tile.within_pending_window(50 + tol)); - // Beyond the future tolerance: rejected. - assert!(!tile.within_pending_window(50 + tol + 1)); - } - - /// Tile (seed separately) plus a spine + adapter, so tests can drive - /// `buffer_orphan`, which produces into `adapter.producers`. The spine is - /// returned to keep it alive for the adapter. - fn tile_with_producers( - wall_slot: u64, - ) -> (BeaconStateTile, TProducer, Box, SpineAdapter) { - use std::sync::atomic::{AtomicU64, Ordering}; - static SEQ: AtomicU64 = AtomicU64::new(0); - let (tile, gp) = make_tile_with_gossip(wall_slot); - let base = std::env::temp_dir().join(format!( - "silver-pending-{}-{}", - std::process::id(), - SEQ.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&base).expect("temp base"); - let mut spine = Box::new(SilverSpine::new_with_base_dir(&base, None)); - let adapter = SpineAdapter::connect_tile(&tile, &mut spine); - (tile, gp, spine, adapter) - } - - fn root_with(idx: u64, tag: u8) -> B256 { - let mut r = [0u8; 32]; - r[..8].copy_from_slice(&idx.to_le_bytes()); - r[31] = tag; - r - } - - /// Buffer an orphan under a distinct missing parent `idx`, just ahead of - /// the head so the slot-distance fallback stays clear. Its own root is in a - /// separate tag namespace so it never collides with another entry. - fn buffer_orphan_idx( - tile: &mut BeaconStateTile, - gp: &mut TProducer, - producers: &mut Producers, - idx: u64, - ) { - let parent = root_with(idx, 0x00); - let block_root = root_with(idx, 0xFF); - let slot = tile.head_state_slot() + 1; - tile.buffer_orphan(parent, block_root, gossip_pending(gp, slot), slot, 0, producers); - } - - #[test] - fn orphan_below_cap_is_buffered() { - let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); - seed_tile(&mut tile, 4, 10); // Following, finalized epoch 0 - let cap = tile.pending_bounds.max_parents; - for i in 0..cap as u64 - 1 { - buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, i); - } - assert_eq!(tile.pending_blocks.len(), cap - 1); - // A new distinct missing parent while below the cap is buffered. - buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, u64::MAX); - assert_eq!(tile.pending_blocks.len(), cap, "orphan buffered below cap"); - } - - #[test] - fn orphan_at_cap_is_refused() { - let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); - seed_tile(&mut tile, 4, 10); - let cap = tile.pending_bounds.max_parents; - for i in 0..cap as u64 { - buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, i); - } - assert_eq!(tile.pending_blocks.len(), cap); - // At the cap, a new distinct missing parent is refused — chain capped. - buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, u64::MAX); - assert_eq!(tile.pending_blocks.len(), cap, "orphan refused at cap"); - } - - #[test] - fn orphan_too_far_ahead_falls_back_to_range_sync() { - let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); - seed_tile(&mut tile, 4, 10); // Following, head slot 10 - let head = tile.head_state_slot(); - let limit = tile.pending_bounds.max_chain_len as u64; - - // At the edge of the gap: still buffered (by-root backtrack). - let edge = head + limit; - tile.buffer_orphan( - root_with(0, 0x00), - root_with(0, 0xFF), - gossip_pending(&mut gp, edge), - edge, - 0, - &mut adapter.producers, - ); - assert_eq!(tile.pending_blocks.len(), 1, "edge orphan buffered"); - - // One slot past the gap: refused before insert, range sync takes over. - let beyond = head + limit + 1; - tile.buffer_orphan( - root_with(1, 0x00), - root_with(1, 0xFF), - gossip_pending(&mut gp, beyond), - beyond, - 0, - &mut adapter.producers, - ); - assert_eq!(tile.pending_blocks.len(), 1, "too-far orphan not buffered"); - } - - #[test] - fn duplicate_orphan_not_rebuffered() { - let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); - seed_tile(&mut tile, 4, 10); - let (parent, block_root) = (root_with(0, 0x00), root_with(0, 0xFF)); - let slot = tile.head_state_slot() + 1; - let buffer = |tile: &mut BeaconStateTile, gp: &mut TProducer, prods: &mut Producers| { - tile.buffer_orphan(parent, block_root, gossip_pending(gp, slot), slot, 0, prods); - }; - buffer(&mut tile, &mut gp, &mut adapter.producers); - buffer(&mut tile, &mut gp, &mut adapter.producers); - assert_eq!(tile.pending_blocks.len(), 1, "same parent"); - assert_eq!(tile.pending_blocks[&parent].len(), 1, "duplicate block_root dropped"); - } - - // ── gossip handlers ── - - #[test] - fn attestation_too_short_ignored() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 10); - let buf = [0u8; 100]; - tile.handle_attestation(&buf, 0); - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, 0); - } - - #[test] - fn ve_unknown_validator_ignored() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let mut buf = [0u8; SIGNED_VOLUNTARY_EXIT_SIZE]; - buf[8..16].copy_from_slice(&999u64.to_le_bytes()); - assert_eq!(tile.handle_voluntary_exit(&buf), Feedback::Ignore); - } - - #[test] - fn ve_accept() { - let mut tile = make_tile(); - // Past shard-committee-period so the exit is permitted. - seed_tile_with_keys(&mut tile, 4, 256 * SLOTS_PER_EPOCH); - let imm = seed_immutable(&tile); - let buf = test_signing::sign_voluntary_exit(0, 0, 0, &imm); - assert_eq!(tile.handle_voluntary_exit(&buf), Feedback::Accept(None)); - } - - #[test] - fn ps_identical_headers_rejected() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let buf = [0u8; PROPOSER_SLASHING_SIZE]; - assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Reject(None)); - } - - #[test] - fn ps_unknown_proposer_ignored() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let mut buf = [0u8; PROPOSER_SLASHING_SIZE]; - buf[8..16].copy_from_slice(&999u64.to_le_bytes()); - buf[216..224].copy_from_slice(&999u64.to_le_bytes()); - buf[208 + 80] = 0xFF; // distinct body_root in h2 - assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Ignore); - } - - #[test] - fn ps_accept() { - let mut tile = make_tile(); - seed_tile_with_keys(&mut tile, 4, 0); - let imm = seed_immutable(&tile); - let buf = test_signing::sign_proposer_slashing(0, 0, 0, &imm); - assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Accept(None)); - } - - #[test] - fn ps_mismatched_slot_rejected() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let mut buf = [0u8; PROPOSER_SLASHING_SIZE]; - buf[208] = 1; // h2.slot differs - assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Reject(None)); - } - - #[test] - fn ps_mismatched_proposer_rejected() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let mut buf = [0u8; PROPOSER_SLASHING_SIZE]; - buf[208 + 8] = 1; // h2.proposer_index differs - assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Reject(None)); - } - - /// IndexedAttestation with `attesting_indices = indices`, zero sig — for - /// structural / state-derived reject tests only. - fn build_ia_with_indices(target_epoch: u64, bbr_marker: u8, indices: &[u64]) -> Vec { - let mut buf = vec![0u8; 228 + indices.len() * 8]; - buf[0..4].copy_from_slice(&228u32.to_le_bytes()); - buf[20] = bbr_marker; - buf[92..100].copy_from_slice(&target_epoch.to_le_bytes()); - for (i, &vi) in indices.iter().enumerate() { - buf[228 + i * 8..228 + (i + 1) * 8].copy_from_slice(&vi.to_le_bytes()); - } - buf - } - - fn wrap_attester_slashing(ia1: &[u8], ia2: &[u8]) -> Vec { - let off1: u32 = 8; - let off2: u32 = off1 + ia1.len() as u32; - let mut buf = Vec::with_capacity(8 + ia1.len() + ia2.len()); - buf.extend_from_slice(&off1.to_le_bytes()); - buf.extend_from_slice(&off2.to_le_bytes()); - buf.extend_from_slice(ia1); - buf.extend_from_slice(ia2); - buf - } - - #[test] - fn as_zero_intersection_rejected() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let ia1 = build_ia_with_indices(0, 0xAA, &[0]); - let ia2 = build_ia_with_indices(0, 0xBB, &[1]); - let buf = wrap_attester_slashing(&ia1, &ia2); - assert_eq!(tile.handle_attester_slashing(&buf), Feedback::Reject(None)); - } - - #[test] - fn as_accept() { - let mut tile = make_tile(); - seed_tile_with_keys(&mut tile, 4, 0); - let imm = seed_immutable(&tile); - let buf = test_signing::sign_attester_slashing_double_vote(0, 0, 0, 0, &imm); - assert_eq!(tile.handle_attester_slashing(&buf), Feedback::Accept(None)); - } - - #[test] - fn as_zero_intersection_with_valid_sigs_rejected() { - let mut tile = make_tile(); - seed_tile_with_keys(&mut tile, 4, 0); - let imm = seed_immutable(&tile); - let ia1 = test_signing::build_indexed_attestation(0, 0, 0, 0, 0, 0xAA, &imm); - let ia2 = test_signing::build_indexed_attestation(1, 1, 0, 0, 0, 0xBB, &imm); - let buf = wrap_attester_slashing(&ia1, &ia2); - assert_eq!(tile.handle_attester_slashing(&buf), Feedback::Reject(None)); - } - - #[test] - fn bls_change_unknown_validator_ignored() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let mut buf = [0u8; SIGNED_BLS_CHANGE_SIZE]; - buf[0..8].copy_from_slice(&999u64.to_le_bytes()); - assert_eq!(tile.handle_bls_to_execution_change(&buf), Feedback::Ignore); - } - - #[test] - fn bls_change_wrong_prefix_rejected() { - let mut tile = make_tile(); - // Validator 0 has ETH1-prefixed credentials in the base; the - // canonical head (anchor over the base) then rejects the change. - let mut eth1 = Withdrawals([0u8; 32]); - eth1.0[0] = 0x01; - let seeds: Vec = (0..4) - .map(|i| ValSeed { - pubkey: placeholder_pubkey(i), - withdrawal_credentials: if i == 0 { eth1 } else { Withdrawals::default() }, - effective_balance: MAX_EFFECTIVE_BALANCE, - balance: MAX_EFFECTIVE_BALANCE, - activation_epoch: 0, - ..Default::default() - }) - .collect(); - let cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; - let epoch_base = epoch_base_with(cp, cp); - arm_tile(&mut tile, epoch_base, &seeds, 0); - let buf = [0u8; SIGNED_BLS_CHANGE_SIZE]; // vi = 0 - assert_eq!(tile.handle_bls_to_execution_change(&buf), Feedback::Reject(None)); - } - - #[test] - fn bls_change_accept() { - let mut tile = make_tile(); - seed_tile_with_keys(&mut tile, 4, 0); - let imm = seed_immutable(&tile); - let to_addr = [0x42u8; 20]; - let buf = test_signing::sign_bls_to_execution_change(0, 0, &to_addr, &imm); - assert_eq!(tile.handle_bls_to_execution_change(&buf), Feedback::Accept(None)); - } - - #[test] - fn block_known_parent_bad_sig_rejected() { - let mut tile = make_tile(); - seed_tile(&mut tile, 128, 10); - - // Known latest_block_header → derive a realistic parent_root and - // anchor fork choice on it. - let genesis_header = BeaconBlockHeader { - slot: 10, - proposer_index: 0, - parent_root: [0u8; 32], - state_root: [0x01; 32], - body_root: [0u8; 32], - }; - { - // Roll a fresh slot fork off the anchor with the known header set - // on the writer, commit, and repoint the head bundle's slot index - // — append-only, no re-open of the published anchor fork. - let new_slot_idx = { - let mut g = tile.state.write(); - let mut sw = g.slot_states.roll_from(tile.last_applied.slot_idx); - sw.state_mut().latest_block_header = genesis_header; - sw.commit() - }; - tile.last_applied.slot_idx = new_slot_idx; - } - let parent_root = ssz_hash::hash_tree_root_block_header(&genesis_header); - - let cp = Checkpoint { epoch: 0, root: parent_root }; - tile.fork_choice = - ForkChoice::init(cp, cp, 10, parent_root, [0u8; 32], false, tile.last_applied, 0); - - // Valid structure, zeroed BLS signature → precheck reaches and fails - // signature verification, so no fork-choice node is added. - let mut buf = vec![0u8; 200]; - buf[100..108].copy_from_slice(&11u64.to_le_bytes()); // slot - buf[108..116].copy_from_slice(&0u64.to_le_bytes()); // proposer_index - buf[116..148].copy_from_slice(&parent_root); // parent_root - - tile.apply_and_publish(&buf, true, false, |_block_root| {}); - assert_eq!(tile.fork_choice.nodes.len(), 1); - } - - // ── attestation / aggregate (committee resolution via shuffling cache) ── - - /// Locate `(slot, committee_index, pos_in_committee, committee_size)` for - /// validator 0 in epoch 0. The seed arms exactly one cache entry per epoch. - fn find_committee_for_vi0(tile: &BeaconStateTile) -> (Slot, usize, usize, usize) { - let shuffled = tile.shuffling_cache.shuffled_by_epoch(0).expect("shuffling for epoch 0"); - let shuffling = stf::EpochShuffling::new(shuffled, tile.head_validator_count()); - for s in 0..SLOTS_PER_EPOCH { - for ci in 0..shuffling.committees_per_slot { - let c = shuffling.committee(s, ci); - if let Some(pos) = c.iter().position(|&v| v == 0) { - return (s, ci, pos, c.len()); - } - } - } - panic!("validator 0 in some committee") - } - - /// Spec `compute_subnet_for_attestation`, recomputed independently of the - /// production helper. - fn expected_subnet(tile: &BeaconStateTile, slot: Slot, ci: usize) -> u64 { - let shuffled = tile - .shuffling_cache - .shuffled_by_epoch(slot / SLOTS_PER_EPOCH) - .expect("shuffling for epoch"); - let cps = - stf::EpochShuffling::new(shuffled, tile.head_validator_count()).committees_per_slot; - (cps as u64 * (slot % SLOTS_PER_EPOCH) + ci as u64) % 64 - } - - fn build_agg_for_vi0(tile: &BeaconStateTile) -> Vec { - let imm = seed_immutable(tile); - let beacon_block_root = tile.last_applied_block_root; - let target_root = tile.last_applied_block_root; - let (slot, ci, pos, csize) = find_committee_for_vi0(tile); - test_signing::sign_aggregate_and_proof( - 0, - 0, - slot, - slot / SLOTS_PER_EPOCH, - beacon_block_root, - target_root, - ci, - pos, - csize, - &imm, - ) - } - - #[test] - fn attestation_updates_vote_tracker() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - // Vote for the (known) anchor block; target is the anchor's checkpoint - // block, so the spec target/ancestor checks accept. - let bbr = tile.last_applied_block_root; - let buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - // Assert against what the handler reads via the view (the view owns - // the offsets), verifying the vote fold self-consistently. - let want_root = *SingleAttestationView::beacon_block_root(&buf); - let want_epoch = SingleAttestationView::target_epoch(&buf); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, want_root); - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, want_epoch); - } - - /// Spec `validate_on_attestation`: a single attestation for a block we - /// don't hold is dropped (Ignore), self-healing on the validator's next - /// vote. - #[test] - fn single_att_unknown_block_ignored() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - let unknown = [0xAAu8; 32]; - let buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - unknown, - slot / SLOTS_PER_EPOCH, - unknown, - &imm, - ); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); - } - - /// Spec `validate_on_attestation`: a known-block vote whose target does not - /// match the block's target-epoch ancestor is rejected. - #[test] - fn single_att_mismatched_target_rejected() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; // known anchor - let wrong_target = [0x77u8; 32]; - let buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - wrong_target, - &imm, - ); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Reject(None)); - } - - /// Fulu: a single attestation with a non-zero `AttestationData.index` is - /// rejected (the committee belongs in `committee_index`). Checked before - /// signature verification, so a zero-signed buffer suffices. - #[test] - fn single_att_nonzero_data_index_rejected() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; - let mut buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - // AttestationData.index @ buf[24..32]; non-zero is illegal post-Electra. - buf[24] = 1; - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Reject(None)); - } - - /// Spec `validate_on_attestation`: a current-slot vote is held until the - /// next slot. It is accepted but not folded until `drain_pending_votes`. - #[test] - fn current_slot_vote_deferred_until_drain() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - // Make the committee slot the current slot → the vote must defer. - tile.ticker.set_current_slot(slot); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; - let buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); - // Deferred: not yet folded into the tracker. - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, [0u8; 32]); - let n = tile.head_validator_count(); - tile.fork_choice.drain_pending_votes(n); - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, bbr); - } - - /// Spec [REJECT]: an attestation must arrive on the subnet its committee - /// maps to. - #[test] - fn single_att_wrong_subnet_rejected() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; - let buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - assert_eq!(tile.handle_attestation(&buf, (subnet + 1) % 64), Feedback::Reject(None)); - // The reject must not have marked the attester seen. - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); - } - - /// Spec [IGNORE]: at most one attestation per (attester, target epoch) — - /// byte-identical or not — and the ignored resend never reaches the pool. - #[test] - fn single_att_repeat_attester_epoch_ignored() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; - let mut buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); - let data_root = - ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); - let first = tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).unwrap(); - - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); - assert_eq!(tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).unwrap(), first); - - // Same attester+epoch, different source epoch (the one AttestationData - // field the handler doesn't validate): first-seen keys on the pair, - // not the content, so the variant must not open a new pool entry. - buf[64..72].copy_from_slice(&1u64.to_le_bytes()); - test_signing::resign_single_attestation(0, &mut buf, &imm); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); - let new_root = - ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); - assert_eq!(tile.attestation_pool.aggregate_ssz(slot, ci as u64, new_root), None); - } - - /// A rejected attestation must not mark the attester seen, or a forged - /// message would censor the validator's honest vote for the epoch. - #[test] - fn single_att_failed_validation_does_not_mark_seen() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, _, _) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; - // Signed with sk 1; validator 0's registry key is pubkey_pk(0). - let bad = test_signing::sign_single_attestation( - 1, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - assert_eq!(tile.handle_attestation(&bad, subnet), Feedback::Reject(None)); - - let honest = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - assert_eq!(tile.handle_attestation(&honest, subnet), Feedback::Accept(None)); - } - - /// An accepted single attestation lands in the pool: participant bit at - /// the attester's committee position, bitlist sized to the real committee, - /// data bytes carried over verbatim. - #[test] - fn single_att_accept_inserts_into_pool() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let (slot, ci, pos, csize) = find_committee_for_vi0(&tile); - let subnet = expected_subnet(&tile, slot, ci); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; - let buf = test_signing::sign_single_attestation( - 0, - 0, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); - - let data_root = - ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); - let out = tile - .attestation_pool - .aggregate_ssz(slot, ci as u64, data_root) - .expect("pooled aggregate"); - let bits = AttestationView::aggregation_bits(&out); - assert!(bits[pos / 8] & (1 << (pos % 8)) != 0); - assert_eq!(merkle::bitlist_len(bits), csize); - assert_eq!( - AttestationView::data(&out).as_bytes(), - SingleAttestationView::data(&buf).as_bytes() - ); - } - - /// Marking a validator equivocating zeroes its live vote and blocks future - /// votes (spec `equivocating_indices` exclusion). - #[test] - fn equivocator_excluded_from_votes() { - let mut tile = make_tile(); - seed_tile(&mut tile, 8, 0); - let anchor = tile.last_applied_block_root; - let n = tile.head_validator_count(); - tile.fork_choice.record_vote( - &AttestationVote { - validator: 3, - block_root: anchor, - target_epoch: 0, - attestation_slot: 0, - payload_present: false, - }, - n, - ); - assert_eq!(tile.fork_choice.vote_tracker.votes[3].latest_root, anchor); - - tile.fork_choice.mark_equivocating(3); - assert!(tile.fork_choice.is_equivocating(3)); - assert_eq!(tile.fork_choice.vote_tracker.votes[3].latest_root, [0u8; 32]); - - // A later attestation from an equivocator is ignored. - tile.fork_choice.record_vote( - &AttestationVote { - validator: 3, - block_root: [0x55u8; 32], - target_epoch: 5, - attestation_slot: 5, - payload_present: false, - }, - n, - ); - assert_eq!(tile.fork_choice.vote_tracker.votes[3].latest_root, [0u8; 32]); - } - - /// The justified-balance snapshot is rebuilt only when the justified - /// checkpoint moves; the first build is a full pass, the next is a no-op. - #[test] - fn justified_balances_rebuilt_on_checkpoint_change_only() { - let mut tile = make_tile(); - seed_tile(&mut tile, 8, 0); - // Anchor justified checkpoint differs from the default → stale → rebuild. - assert!(tile.fork_choice.justified_balances_stale()); - tile.refresh_justified_balances(); - assert!(!tile.fork_choice.justified_balances_stale()); - assert_eq!(tile.fork_choice.justified_balances.len(), 8); - assert!(tile.fork_choice.justified_balances.iter().all(|&b| b == MAX_EFFECTIVE_BALANCE)); - // Total active balance is cached in the same sweep: all 8 active and - // unslashed → 8 × MAX_EFFECTIVE_BALANCE (proposer boost reads this - // instead of re-sweeping per block). - assert_eq!(tile.fork_choice.justified_total_active_balance(), 8 * MAX_EFFECTIVE_BALANCE); - // Unchanged checkpoint → no rebuild (idempotent). - tile.refresh_justified_balances(); - } - - #[test] - fn agg_multi_committee_bits_rejected() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let mut buf = vec![0u8; SIGNED_AGG_PROOF_MIN]; - buf[436] = 0b0000_0011; // two committee bits - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); - } - - #[test] - fn agg_unknown_block_root_ignored() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let mut buf = vec![0u8; SIGNED_AGG_PROOF_MIN]; - buf[436] = 0b0000_0001; // single committee bit - buf[228] = 0xFF; // beacon_block_root not in fork choice - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); - } - - #[test] - fn agg_accept() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let buf = build_agg_for_vi0(&tile); - let beacon_block_root = tile.last_applied_block_root; - let slot = SignedAggregateAndProofView::agg_slot(&buf); - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, beacon_block_root); - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, slot / SLOTS_PER_EPOCH); - } - - #[test] - fn agg_respects_epoch_monotonicity() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - - let preset_root = [0x99u8; 32]; - tile.fork_choice.vote_tracker.votes[0].latest_root = preset_root; - tile.fork_choice.vote_tracker.votes[0].latest_epoch = 1; - - let buf = build_agg_for_vi0(&tile); - assert_eq!(SignedAggregateAndProofView::agg_target_epoch(&buf), 0); - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); - - // Older-epoch aggregate must not overwrite the newer vote. - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, preset_root); - assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, 1); - } - - #[test] - fn agg_slot_too_old_ignored() { - let mut tile = make_tile_at_wall_slot(100); - seed_tile_with_keys(&mut tile, 128, 0); - let buf = build_agg_for_vi0(&tile); - assert!( - SignedAggregateAndProofView::agg_slot(&buf) < 100 - ATTESTATION_PROPAGATION_SLOT_RANGE - ); - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); - } - - #[test] - fn agg_slot_too_future_ignored() { - let mut tile = make_tile_at_wall_slot(0); - seed_tile(&mut tile, 128, 0); - let mut buf = vec![0u8; SIGNED_AGG_PROOF_MIN]; - buf[436] = 0b0000_0001; - buf[212] = 5; // slot = 5 > wall (0) - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); - } - - #[test] - fn agg_committee_index_oor_rejected() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let mut buf = build_agg_for_vi0(&tile); - for i in 0..8 { - buf[436 + i] = 0; - } - buf[436] = 0b0000_0010; // committee_index 1, OOR for committees_per_slot=1 - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); - } - - #[test] - fn agg_is_aggregator_false_rejected() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 1024, 0); - let mut buf = build_agg_for_vi0(&tile); - let (_, _, _, csize) = find_committee_for_vi0(&tile); - assert_eq!(csize, 32, "committee_len drives the aggregator modulo"); - - let sp_off = 112usize; - let mut sig_arr: [u8; 96] = buf[sp_off..sp_off + 96].try_into().unwrap(); - let mut b: u16 = 0; - loop { - sig_arr[0] = b as u8; - if !gossip::is_aggregator(csize, &sig_arr) { - break; - } - b += 1; - assert!(b < 256, "no parity-flipping byte found (impossible)"); - } - buf[sp_off..sp_off + 96].copy_from_slice(&sig_arr); - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); - } - - /// Spec [IGNORE]: at most one aggregate per (aggregator, target epoch) — - /// a byte-identical resend dies on the seen probe. - #[test] - fn agg_repeat_aggregator_epoch_ignored() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let buf = build_agg_for_vi0(&tile); - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); - } - - /// A rejected aggregate must not mark the aggregator seen, or a forged - /// message would censor the aggregator's real aggregate for the epoch. - #[test] - fn agg_failed_validation_does_not_mark_aggregator() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let buf = build_agg_for_vi0(&tile); - let mut forged = buf.clone(); - forged[50] ^= 0xFF; // outer signature = buf[4..100) - assert_eq!(tile.handle_aggregate_and_proof(&forged), Feedback::Reject(None)); - assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); - } - - /// First committee (skipping the wall slot) holding two members whose - /// registry keys differ (vi % 3), so the aggregate is genuinely multi-key. - fn find_committee_with_two_signers(tile: &BeaconStateTile) -> (Slot, usize, u32, u32) { - let shuffled = tile.shuffling_cache.shuffled_by_epoch(0).expect("shuffling for epoch 0"); - let shuffling = stf::EpochShuffling::new(shuffled, tile.head_validator_count()); - for s in 0..SLOTS_PER_EPOCH - 1 { - for ci in 0..shuffling.committees_per_slot { - let c = shuffling.committee(s, ci); - for (i, &a) in c.iter().enumerate() { - if let Some(&b) = c[i + 1..].iter().find(|&&b| b % 3 != a % 3) { - return (s, ci, a, b); - } - } - } - } - panic!("two distinct-key members in some committee") - } - - fn committee_of(tile: &BeaconStateTile, slot: Slot, ci: usize) -> Vec { - let shuffled = tile - .shuffling_cache - .shuffled_by_epoch(slot / SLOTS_PER_EPOCH) - .expect("shuffling for epoch"); - stf::EpochShuffling::new(shuffled, tile.head_validator_count()).committee(slot, ci).to_vec() - } - - /// Wrap an inner aggregate with `vi` as aggregator (registry keys cycle - /// `vi % 3`). - fn wrap_by(imm: &Immutable, vi: u32, aggregate: &[u8]) -> Vec { - test_signing::wrap_aggregate_and_proof(vi as usize % 3, vi as u64, aggregate, imm) - } - - /// Sign `vi`'s single attestation for the anchor at `(slot, ci)`, feed it - /// through `handle_attestation`, and return the grown pooled aggregate. - fn pool_single_then_aggregate( - tile: &mut BeaconStateTile, - vi: u32, - slot: Slot, - ci: usize, - ) -> Vec { - let imm = seed_immutable(tile); - let bbr = tile.last_applied_block_root; - let buf = test_signing::sign_single_attestation( - vi as usize % 3, - vi as u64, - ci as u64, - slot, - bbr, - slot / SLOTS_PER_EPOCH, - bbr, - &imm, - ); - let data_root = - ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); - let subnet = expected_subnet(tile, slot, ci); - assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); - tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).expect("pooled aggregate") - } - - #[test] - fn pool_aggregate_accepted_by_aggregate_and_proof_path() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let imm = seed_immutable(&tile); - let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - - pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - let aggregate = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); - - // Committee of 4 < 16 ⇒ any member trivially passes is_aggregator. - let wrapped = wrap_by(&imm, vi_a, &aggregate); - // Accept requires verify_aggregate_and_proof_sigs: selection proof, - // outer signature, and the pooled aggregate signature against the two - // participants' aggregated registry pubkeys. - assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Accept(None)); - } - - /// First-seen keys on (aggregator, target epoch), not message bytes: a - /// second, different-but-valid aggregate from the same aggregator is - /// still ignored. - #[test] - fn agg_repeat_keys_on_aggregator_not_bytes() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let imm = seed_immutable(&tile); - let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - - let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_one)), - Feedback::Accept(None) - ); - - // A second participant grows the pooled aggregate: different bytes, - // fully valid, same (aggregator, target epoch). - let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); - assert_ne!(agg_two, agg_one); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), - Feedback::Ignore - ); - } - - /// Neither admission rule keys on the attestation data alone: aggregates - /// over the same data from two distinct aggregators both accept, provided - /// the second grows bit coverage (equal bits die on the superset rule). - #[test] - fn agg_distinct_aggregators_same_data_both_accept() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let imm = seed_immutable(&tile); - let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - - let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_one)), - Feedback::Accept(None) - ); - - let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_two)), - Feedback::Accept(None) - ); - } - - /// Spec [IGNORE]: bits ⊆ an already-seen valid aggregate's — equal or - /// strictly smaller — die on the coverage probe regardless of who - /// aggregated them. - #[test] - fn agg_subset_from_other_aggregator_ignored() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let imm = seed_immutable(&tile); - let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - - let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), - Feedback::Accept(None) - ); - - // Both from an aggregator the epoch has not seen, so only the - // coverage rule can be what ignores them. - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_two)), - Feedback::Ignore - ); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_one)), - Feedback::Ignore - ); - } - - /// The superset gate fires before signature verification: a covered - /// message with a corrupted outer signature probes Ignore instead of - /// reaching the Reject the signature would earn. Skipping that ~1 ms - /// batch verify is the point of the coverage rule. - #[test] - fn agg_superset_gate_precedes_signature_verify() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let imm = seed_immutable(&tile); - let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - - let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), - Feedback::Accept(None) - ); - - let mut forged = wrap_by(&imm, vi_b, &agg_one); - forged[50] ^= 0xFF; // outer signature = buf[4..100) - assert_eq!(tile.handle_aggregate_and_proof(&forged), Feedback::Ignore); - } - - /// Union-covered bits (inside the OR of seen patterns, ⊆ none singly) - /// must still verify and relay: union coverage sheds only the local vote - /// fold, never forwarding. - #[test] - fn agg_union_covered_still_relays() { - let mut tile = make_tile_at_wall_slot(31); - seed_tile_with_keys(&mut tile, 128, 0); - let imm = seed_immutable(&tile); - let bbr = tile.last_applied_block_root; - let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); - let committee = committee_of(&tile, slot, ci); - let vi_c = *committee.iter().find(|&&v| v != vi_a && v != vi_b).expect("committee of 4"); - let pos_b = committee.iter().position(|&v| v == vi_b).unwrap(); - - // {a} from aggregator a, then {b} alone from aggregator b: disjoint - // patterns whose union is {a, b}. - let agg_a = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_a)), - Feedback::Accept(None) - ); - let agg_b_only = test_signing::sign_aggregate_and_proof( - vi_b as usize % 3, - vi_b as u64, - slot, - slot / SLOTS_PER_EPOCH, - bbr, - bbr, - ci, - pos_b, - committee.len(), - &imm, - ); - assert_eq!(tile.handle_aggregate_and_proof(&agg_b_only), Feedback::Accept(None)); - - // {a, b} from a third aggregator: within the union, inside neither. - let agg_ab = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); - assert_eq!( - tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_c, &agg_ab)), - Feedback::Accept(None) - ); - } - - // ── finalization (deposit append lands on the delta, not the base) ── - - /// A `PendingDeposit` for a brand-new validator (spec key 0), signed under - /// the genesis deposit domain (fork version + gvr both zero). - fn signed_new_validator_deposit() -> (BLSPubkey, PendingDeposit) { - const DOMAIN_DEPOSIT: u32 = 0x03; - let pk = test_signing::pubkey_bytes(0); - let wc = Withdrawals([0xAAu8; 32]); - let amount = 32_000_000_000u64; - - let msg_root = - merkle::merkleize(&[merkle::hash_fixed_bytes(&pk), wc.0, merkle::uint64_chunk(amount)]); - let domain = bls::compute_domain(DOMAIN_DEPOSIT, [0; 4], &[0u8; 32]); - let signing_root = bls::compute_signing_root(&msg_root, &domain); - let sig = test_signing::sign(0, &signing_root); - - (pk, PendingDeposit { - pubkey: pk, - withdrawal_credentials: wc, - amount, - signature: sig, - slot: 0, - }) - } - - /// Speculative validator appends from deposit processing must land on the - /// head fork's `validators` delta, not on the shared finalized base — - /// the base only advances at Casper finality. - #[test] - fn epoch_transition_keeps_base_until_finality() { - let mut tile = make_tile(); - // Arm with epoch 1 already finalized in the base (finality lives in the - // base, so this is equivalent to advancing it — and avoids mutating the - // published base mid-test) so slot-0 deposits are eligible. - let (_eb, seeds) = build_seed_finalized(1, false); - let epoch_base = epoch_base_with(Checkpoint { epoch: 0, root: ANCHOR_ROOT }, Checkpoint { - epoch: 1, - root: ANCHOR_ROOT, - }); - arm_tile(&mut tile, epoch_base, &seeds, 0); - - let (pk, deposit) = signed_new_validator_deposit(); - // Queue the deposit append-only: roll a fresh pending fork off the - // head, push, and repoint the head bundle at the committed id. - tile.last_applied.pending_idx = { - let mut g = tile.state.write(); - let mut pw = g.pending.roll_from(tile.last_applied.pending_idx); - pw.deposits.push(deposit); - pw.commit() - }; - - tile.on_slot_start(SLOTS_PER_EPOCH); - - // Base unchanged; the new validator lives on the head fork's delta. - assert_eq!( - tile.state.state().validators.finalized().validator_count(), - 1, - "base must wait for finality" - ); - let head_validators = tile.state.state().validators.view(tile.last_applied.validators_idx); - assert_eq!(head_validators.count(), 2); - assert_eq!(*head_validators.pubkey(1), pk); - } - - /// `maybe_finalize` with `fin_idx > 0` must (a) promote the finalized - /// delta into the base, (b) prune non-descendant siblings from fork - /// choice, (c) re-base the surviving descendant's cumulative edits against - /// the new base, and (d) hand the survivor its re-anchored bundle. This is - /// the only place the survivor re-base path is exercised — single-fork - /// tests take the no-promote branch (`fin_idx == 0`). - #[test] - fn multi_fork_finalize_promotes_and_rebases() { - let mut tile = make_tile(); - seed_tile(&mut tile, 4, 0); - let anchor_id = tile.last_applied; - - const F_ROOT: B256 = [0x0F; 32]; - const D_ROOT: B256 = [0x0D; 32]; - const F2_ROOT: B256 = [0xF2; 32]; - const ZERO_CP: Checkpoint = Checkpoint { epoch: 0, root: [0u8; 32] }; - let f_cp = Checkpoint { epoch: 0, root: F_ROOT }; - - // Roll a slot-group fork off `parent` with a slot number + one - // cumulative block root set on the writer, then commit — the - // writer→commit path (no in-place re-open). `reset_from` carries the - // parent's root tail, so a child appends onto it. - let roll_slot = |st: &mut BeaconStateOwner, parent: SlotStateId, slot: Slot, root: B256| { - let mut g = st.write(); - let mut sw = g.slot_states.roll_from(parent); - sw.state_mut().slot = slot; - sw.push_block_root(root); - sw.commit() - }; - let roll_balances = |st: &mut BeaconStateOwner, parent| { - let mut g = st.write(); - g.balances.roll_from(parent).commit() - }; - - // F: child of anchor (to be finalized). One cumulative `block_root`. - // Each fork's bundle copies its parent's and re-points the rolled - // tiers (slot + balances here, the others shared for this test). - let f_id = StateId { - balances_idx: roll_balances(&mut tile.state, anchor_id.balances_idx), - slot_idx: roll_slot(&mut tile.state, anchor_id.slot_idx, 1, F_ROOT), - ..anchor_id - }; - - // D: child of F (head, survives). Inherits F's block_roots via - // `reset_from`, appends its own → [F_ROOT, D_ROOT]. - let d_id = StateId { - balances_idx: roll_balances(&mut tile.state, f_id.balances_idx), - slot_idx: roll_slot(&mut tile.state, f_id.slot_idx, 2, D_ROOT), - ..f_id - }; - - // F2: sibling of F (will be pruned by fork choice). - let f2_id = StateId { - balances_idx: roll_balances(&mut tile.state, anchor_id.balances_idx), - slot_idx: roll_slot(&mut tile.state, anchor_id.slot_idx, 1, F2_ROOT), - ..anchor_id - }; - - // Insert F2 first so its idx is below F's — fork choice's `prune` - // only drops the prefix below the finalized node, so the sibling has - // to live ahead of the to-be-finalized node to be reclaimed. - tile.fork_choice.on_block(BlockImport { - slot: 1, - block_root: F2_ROOT, - parent_root: ANCHOR_ROOT, - execution_block_hash: [0u8; 32], - justified: ZERO_CP, - finalized: ZERO_CP, - unrealized_justified: ZERO_CP, - unrealized_finalized: ZERO_CP, - state_id: f2_id, - bid_block_hash: [0u8; 32], - parent_payload_status: PayloadStatus::Full, - payload_verified: true, - is_gloas: false, - }); - tile.fork_choice.on_block(BlockImport { - slot: 1, - block_root: F_ROOT, - parent_root: ANCHOR_ROOT, - execution_block_hash: [0u8; 32], - justified: f_cp, - finalized: f_cp, - unrealized_justified: f_cp, - unrealized_finalized: f_cp, - state_id: f_id, - bid_block_hash: [0u8; 32], - parent_payload_status: PayloadStatus::Full, - payload_verified: true, - is_gloas: false, - }); - tile.fork_choice.on_block(BlockImport { - slot: 2, - block_root: D_ROOT, - parent_root: F_ROOT, - execution_block_hash: [0u8; 32], - justified: f_cp, - finalized: f_cp, - unrealized_justified: f_cp, - unrealized_finalized: f_cp, - state_id: d_id, - bid_block_hash: [0u8; 32], - parent_payload_status: PayloadStatus::Full, - payload_verified: true, - is_gloas: false, - }); - - // Head is D; finality target is F. - tile.last_applied = d_id; - tile.last_applied_block_root = D_ROOT; - tile.fork_choice.finalized_checkpoint = f_cp; - // Republish so the seqlock control matches the new head. - tile.state.publish_state_id(d_id); - - // Sanity: pre-finalize state. - assert_eq!(tile.state.state().slot_states.finalized_view().slot_number(), 0); - let d_slot_view = tile.state.state().slot_states.view(d_id.slot_idx); - assert_eq!(d_slot_view.delta_block_roots(), [F_ROOT, D_ROOT]); - assert!(tile.fork_choice.find_node_idx(&F2_ROOT).is_some()); - - tile.maybe_finalize(); - - // (a) Base advanced to F's slot scalars; F's block_root landed in the - // circular buffer at the old finalized slot offset. - let base = tile.state.state().slot_states.finalized_view(); - assert_eq!(base.slot_number(), 1, "base slot promoted to F's slot"); - assert_eq!( - base.finalized_block_roots()[0], - F_ROOT, - "F's block_root in base circular buffer" - ); - - // (b) F2 pruned from fork choice; F is now node 0 (anchor); D survives. - assert!(tile.fork_choice.find_node_idx(&F2_ROOT).is_none(), "F2 dropped"); - assert_eq!(tile.fork_choice.find_node_idx(&F_ROOT), Some(0), "F is the new anchor"); - let d_node = tile.fork_choice.find_node_idx(&D_ROOT).expect("D survives"); - - // (c) D's cumulative `block_roots` log re-based against the new base: - // F's prefix drained, only D's incremental entry remains. Finalize - // re-anchored D into a fresh slot fork, so re-read its bundle from - // the fork-choice node. - let d_rebased = tile.fork_choice.node(d_node).state_id; - let d_slot_view = tile.state.state().slot_states.view(d_rebased.slot_idx); - assert_eq!( - d_slot_view.delta_block_roots(), - [D_ROOT], - "D's block_roots drained of F's prefix" - ); - - // (d) D was the head, so `last_applied` got the same re-anchored - // bundle (not the stale pre-finalize one). - assert_eq!(tile.last_applied, d_rebased, "head bundle refreshed"); - assert_ne!(tile.last_applied, d_id, "stale head bundle replaced"); - } - - // ── fork_digest (standalone `compute_fork_digest`, no tile state) ── - - const FD_SENTINEL: BlobParameters = BlobParameters { epoch: u64::MAX, max_blobs_per_block: 0 }; - - fn fd_genesis_validators_root(b: u8) -> B256 { - [b; 32] - } - - fn fd_ef_schedule() -> [BlobParameters; 6] { - [ - BlobParameters { epoch: 9, max_blobs_per_block: 9 }, - BlobParameters { epoch: 100, max_blobs_per_block: 100 }, - BlobParameters { epoch: 150, max_blobs_per_block: 175 }, - BlobParameters { epoch: 200, max_blobs_per_block: 200 }, - BlobParameters { epoch: 250, max_blobs_per_block: 275 }, - BlobParameters { epoch: 300, max_blobs_per_block: 300 }, - ] - } - - fn fd_ef_digest(epoch: Epoch, fork_version: Version, gvr: &B256) -> [u8; 4] { - let schedule = fd_ef_schedule(); - let bp = get_blob_parameters(epoch, &schedule, FD_SENTINEL); - compute_fork_digest(fork_version, gvr, Some(bp)) - } - - #[test] - fn ef_compute_fork_digest_vectors() { - let v6 = [0x06, 0x00, 0x00, 0x00]; - let v61 = [0x06, 0x00, 0x00, 0x01]; - let v7 = [0x07, 0x00, 0x00, 0x00]; - let v71 = [0x07, 0x00, 0x00, 0x01]; - - let cases: &[(Epoch, Version, B256, [u8; 4])] = &[ - (9, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (10, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (11, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (99, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), - (100, v6, fd_genesis_validators_root(0), [0xdf, 0x67, 0x55, 0x7b]), - (101, v6, fd_genesis_validators_root(0), [0xdf, 0x67, 0x55, 0x7b]), - (150, v6, fd_genesis_validators_root(0), [0x8a, 0xb3, 0x8b, 0x59]), - (199, v6, fd_genesis_validators_root(0), [0x8a, 0xb3, 0x8b, 0x59]), - (200, v6, fd_genesis_validators_root(0), [0xd9, 0xb8, 0x14, 0x38]), - (201, v6, fd_genesis_validators_root(0), [0xd9, 0xb8, 0x14, 0x38]), - (250, v6, fd_genesis_validators_root(0), [0x4e, 0xf3, 0x2a, 0x62]), - (299, v6, fd_genesis_validators_root(0), [0x4e, 0xf3, 0x2a, 0x62]), - (300, v6, fd_genesis_validators_root(0), [0xca, 0x10, 0x0d, 0x64]), - (301, v6, fd_genesis_validators_root(0), [0xca, 0x10, 0x0d, 0x64]), - (9, v6, fd_genesis_validators_root(1), [0x89, 0x67, 0x11, 0x11]), - (9, v6, fd_genesis_validators_root(2), [0xf4, 0x9b, 0x0e, 0x24]), - (9, v6, fd_genesis_validators_root(3), [0x86, 0x54, 0x4e, 0x4f]), - (100, v6, fd_genesis_validators_root(1), [0xfd, 0x3a, 0xa2, 0xa2]), - (100, v6, fd_genesis_validators_root(2), [0x80, 0xc6, 0xbd, 0x97]), - (100, v6, fd_genesis_validators_root(3), [0xf2, 0x09, 0xfd, 0xfc]), - (9, v61, fd_genesis_validators_root(0), [0x30, 0xf8, 0xc2, 0x5b]), - (9, v7, fd_genesis_validators_root(0), [0x04, 0x32, 0xf5, 0xa9]), - (9, v71, fd_genesis_validators_root(0), [0x6e, 0x69, 0xa6, 0x71]), - (100, v61, fd_genesis_validators_root(0), [0x44, 0xa5, 0x71, 0xe8]), - (100, v7, fd_genesis_validators_root(0), [0x70, 0x6f, 0x46, 0x1a]), - (100, v71, fd_genesis_validators_root(0), [0x1a, 0x34, 0x15, 0xc2]), - ]; - - for (epoch, fv, g, expected) in cases { - let got = fd_ef_digest(*epoch, *fv, g); - assert_eq!( - got, *expected, - "epoch={epoch} fv={fv:02x?} gvr[0]={:#04x}: got {got:02x?}, want {expected:02x?}", - g[0] - ); - } - } - - #[test] - fn mainnet_fulu_fork_digest_419072() { - let mainnet_gvr: B256 = [ - 0x4b, 0x36, 0x3d, 0xb9, 0x4e, 0x28, 0x61, 0x20, 0xd7, 0x6e, 0xb9, 0x05, 0x34, 0x0f, - 0xdd, 0x4e, 0x54, 0xbf, 0xe9, 0xf0, 0x6b, 0xf3, 0x3f, 0xf6, 0xcf, 0x5a, 0xd2, 0x7f, - 0x51, 0x1b, 0xfe, 0x95, - ]; - let spec = SpecConfig::mainnet(); - let bp = get_blob_parameters(419072, &spec.blob_schedule, spec.default_blob_params()); - assert_eq!(bp, BlobParameters { epoch: 419072, max_blobs_per_block: 21 }); - - let digest = compute_fork_digest(spec.fulu_fork_version, &mainnet_gvr, Some(bp)); - assert_eq!(digest, [0x8c, 0x9f, 0x62, 0xfe]); - } -} +mod tests; diff --git a/crates/beacon_state/tile/src/tile/tests.rs b/crates/beacon_state/tile/src/tile/tests.rs new file mode 100644 index 00000000..c177ad48 --- /dev/null +++ b/crates/beacon_state/tile/src/tile/tests.rs @@ -0,0 +1,1679 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use flux::timing::Nanos; +use silver_beacon_state_data::{ + BLSPubkey, BeaconBlockHeader, BeaconState, EPOCHS_PER_HISTORICAL_VECTOR, + EPOCHS_PER_SLASHINGS_VECTOR, EpochState, EpochStateFinalized, Immutable, + PROPOSER_LOOKAHEAD_SIZE, PendingDeposit, SlotStateId, ValSeed, Withdrawals, +}; +use silver_common::{ + GossipTopic, MessageId, P2pStreamId, StreamProtocol, TCache, TCacheProducer, TProducer, + ssz_view::{ + AttestationView, PROPOSER_SLASHING_SIZE, SIGNED_AGG_PROOF_MIN, SIGNED_BLS_CHANGE_SIZE, + SIGNED_VOLUNTARY_EXIT_SIZE, SignedAggregateAndProofView, SingleAttestationView, + }, +}; + +use super::*; +use crate::{ + fork_choice::{BlockImport, PayloadStatus}, + stf::AttestationVote, + test_signing, +}; + +const MAX_EFFECTIVE_BALANCE: u64 = 32_000_000_000; +const ANCHOR_ROOT: B256 = [0x01u8; 32]; + +fn make_tile() -> BeaconStateTile { + make_tile_at_wall_slot(1) +} + +/// Tile whose ticker reports `wall_slot` as the current slot. +fn make_tile_at_wall_slot(wall_slot: u64) -> BeaconStateTile { + make_tile_at_wall_slot_ws(wall_slot, true) +} + +fn make_tile_at_wall_slot_ws(wall_slot: u64, verify_weak_subjectivity: bool) -> BeaconStateTile { + let secs_per_slot = 12u64; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let genesis = now.saturating_sub(wall_slot * secs_per_slot + 1); + let ticker = SlotTicker::new(genesis, Duration::from_secs(12), Duration::from_secs(4)); + let gossip_p = TCache::producer("test_gossip", 1 << 20); + let event_p = TCache::producer("test_event", 1 << 20); + let engine_p = TCache::producer("test_engine", 1 << 20); + let replay_p = TCache::producer("test_replay", 1 << 20); + let gossip_c = gossip_p.cache_ref().random_access("test_gossip", true).unwrap(); + let rpc_c = event_p.cache_ref().random_access("test_event", true).unwrap(); + let engine_c = engine_p.cache_ref().random_access("test_engine", true).unwrap(); + let replay_c = replay_p.cache_ref().random_access("test_replay", true).unwrap(); + BeaconStateTile::new( + ticker, + Arc::new(SpecConfig::mainnet()), + &SyncingConfig::default(), + gossip_c, + rpc_c, + engine_c, + replay_c, + verify_weak_subjectivity, + BeaconState::empty_test(0), + ) +} + +/// Like `make_tile_at_wall_slot` but returns the gossip producer so tests +/// can write real block buffers the tile's consumer can read back. +fn make_tile_with_gossip(wall_slot: u64) -> (BeaconStateTile, TProducer) { + let secs_per_slot = 12u64; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let genesis = now.saturating_sub(wall_slot * secs_per_slot + 1); + let ticker = SlotTicker::new(genesis, Duration::from_secs(12), Duration::from_secs(4)); + let gossip_p = TCache::producer("test_gossip_buf", 1 << 20); + let event_p = TCache::producer("test_event_buf", 1 << 20); + let engine_p = TCache::producer("test_engine", 1 << 20); + let replay_p = TCache::producer("test_replay_buf", 1 << 20); + let gossip_c = gossip_p.cache_ref().random_access("test_gossip_buf", true).unwrap(); + let rpc_c = event_p.cache_ref().random_access("test_event_buf", true).unwrap(); + let engine_c = engine_p.cache_ref().random_access("test_engine", true).unwrap(); + let replay_c = replay_p.cache_ref().random_access("test_replay_buf", true).unwrap(); + let tile = BeaconStateTile::new( + ticker, + Arc::new(SpecConfig::mainnet()), + &SyncingConfig::default(), + gossip_c, + rpc_c, + engine_c, + replay_c, + true, + BeaconState::empty_test(0), + ); + (tile, gossip_p) +} + +/// Publish a minimal block (slot at offset 100) into `producer` and wrap it +/// as a buffered gossip orphan whose slot the tile can read back. +fn gossip_pending(producer: &mut TProducer, slot: u64) -> PendingBlock { + let mut bytes = vec![0u8; 200]; + bytes[100..108].copy_from_slice(&slot.to_le_bytes()); + let mut r = producer.reserve(bytes.len(), true).expect("reserve"); + if let Ok(buf) = r.buffer() { + buf[..bytes.len()].copy_from_slice(&bytes); + } + r.increment_offset(bytes.len()); + let read = r.read(); + producer.publish_head(); + PendingBlock::Gossip(NewGossipMsg { + stream_id: P2pStreamId::new(0, 0, StreamProtocol::Unset, false), + topic: GossipTopic::BeaconBlock, + msg_hash: MessageId { id: [0u8; 20] }, + recv_ts: Nanos(0), + ssz: read, + protobuf: read, + }) +} + +fn placeholder_pubkey(i: usize) -> BLSPubkey { + let mut pk = [0u8; 48]; + pk[..4].copy_from_slice(&(i as u32).to_le_bytes()); + pk +} + +/// Epoch-tier base with zeroed rings and the given checkpoints seeded — +/// the harness analog of a decomposed anchor. +fn epoch_base_with(justified: Checkpoint, finalized: Checkpoint) -> EpochStateFinalized { + EpochStateFinalized::from_parts( + EpochState { + current_justified_checkpoint: justified, + finalized_checkpoint: finalized, + ..Default::default() + }, + vec![[0u8; 32]; EPOCHS_PER_HISTORICAL_VECTOR].into_boxed_slice(), + vec![0u64; EPOCHS_PER_SLASHINGS_VECTOR].into_boxed_slice(), + ) +} + +/// Build seed bases with `n` active validators (MAX effective balance, +/// activation epoch 0, exit FAR_FUTURE) at `start_slot`. With `real_keys`, +/// install spec BLS test pubkeys (+ BLS-prefix withdrawal creds) so +/// signature-checking handlers accept; otherwise collision-free +/// placeholder pubkeys. +fn build_seed_finalized(n: usize, real_keys: bool) -> (EpochStateFinalized, Vec) { + let cp_fin = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + let seeds: Vec = (0..n) + .map(|i| { + let (pubkey, withdrawal_credentials) = if real_keys { + let sk_idx = i % test_signing::PRIVKEY_HEX.len(); + let pk_bytes = test_signing::pubkey_pk(sk_idx).to_bytes(); + // BLS-prefix creds: [0]=0x00, [1..]=hash(pk)[1..]. + let mut creds = Withdrawals(merkle::sha256(&pk_bytes)); + creds.0[0] = 0x00; + (pk_bytes, creds) + } else { + (placeholder_pubkey(i), Withdrawals::default()) + }; + ValSeed { + pubkey, + withdrawal_credentials, + effective_balance: MAX_EFFECTIVE_BALANCE, + balance: MAX_EFFECTIVE_BALANCE, + activation_epoch: 0, + ..Default::default() + } + }) + .collect(); + // The finalized slot lives in the slot-group base (seeded in + // `arm_tile` from `start_slot`); the validator registry rides its own + // group (seeded in `arm_tile` from `seeds`); the epoch tier rides its + // own group (built in `arm_tile` from this base). + (epoch_base_with(cp_fin, cp_fin), seeds) +} + +/// Install the seed bases as the tile's state, anchor an empty per-fork +/// delta seeded with the finalized slot scalars, and arm fork choice + the +/// start-epoch attester shuffling at the anchor. +fn arm_tile( + tile: &mut BeaconStateTile, + epoch_base: EpochStateFinalized, + seeds: &[ValSeed], + start_slot: Slot, +) { + // Test-built state: epoch base from `epoch_base`, registry + balances + // column from `seeds`, slot base anchored at `start_slot`, the rest + // empty. + let mut bs = BeaconState::for_test(epoch_base, seeds, start_slot); + // Anchor each tier's fork at the base (the slot tier at `start_slot`); + // epoch/longtail stay lazy. Rolled before the owner wraps the state. + let anchor = bs.roll_fresh(); + let mut owner = BeaconStateOwner::new(bs); + owner.publish_state_id(anchor); + + tile.state = owner; + tile.shuffling_cache = ShufflingCache::with_capacity(seeds.len()); + tile.last_applied = anchor; + tile.last_applied_block_root = ANCHOR_ROOT; + tile.mode = Mode::Following; + + let cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + tile.fork_choice = + ForkChoice::init(cp, cp, start_slot, ANCHOR_ROOT, [0u8; 32], false, anchor, seeds.len()); + + let view = tile.state.read_view(anchor); + tile.shuffling_cache.ensure_window(&view, start_slot / SLOTS_PER_EPOCH); +} + +fn seed_tile(tile: &mut BeaconStateTile, n: usize, start_slot: Slot) { + let (epoch_base, seeds) = build_seed_finalized(n, false); + arm_tile(tile, epoch_base, &seeds, start_slot); +} + +fn seed_tile_with_keys(tile: &mut BeaconStateTile, n: usize, start_slot: Slot) { + let (epoch_base, seeds) = build_seed_finalized(n, true); + arm_tile(tile, epoch_base, &seeds, start_slot); +} + +/// Immutable tier the signed-object builders sign against. `seed_*` leave +/// the base immutable all-default, so a default crate `Immutable` matches +/// the common one the handlers read, field-for-field (fork versions + gvr +/// all zero → identical signing domains). +fn seed_immutable(_tile: &BeaconStateTile) -> Immutable { + Immutable::default() +} + +#[test] +fn slot_advance_skip_multiple() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 10); + tile.on_slot_start(15); + assert_eq!(tile.head_state_slot(), 15); +} + +#[test] +fn slot_advance_noop_past_slot() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 10); + tile.on_slot_start(5); + assert_eq!(tile.head_state_slot(), 10); +} + +#[test] +fn slot_advance_crosses_epoch_boundary() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 30); + tile.on_slot_start(34); + assert_eq!(tile.head_state_slot(), 34); + // Crossing into epoch 1 allocated the head fork its own epoch delta. + assert!(tile.last_applied.epoch_idx.is_some()); +} + +/// Sustained non-finality: slot advances far past the rings' initial +/// capacity (slot tiers past `SLOTS_RING_N` twice over, the epoch tier +/// past `EPOCHS_RING_N`) must grow the rings instead of panicking on +/// wrap, with the head still resolving after every advance. +#[test] +fn slot_advance_grows_rings_under_non_finality() { + use silver_beacon_state_data::{SLOTS_PER_EPOCH, SLOTS_RING_N}; + + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let target = SLOTS_RING_N as u64 * 2 + 3 * SLOTS_PER_EPOCH; + for s in 1..=target { + tile.on_slot_start(s); + assert_eq!(tile.head_state_slot(), s); + } +} + +/// Regression: advancing the head over an empty epoch-boundary slot must +/// COW the epoch tier, not shift the parent's shared `proposer_lookahead` +/// in place. The parent stays a live fork-choice node the proposer +/// precheck reads; an in-place shift left its next-epoch slice one epoch +/// too far, rejecting valid boundary blocks. +#[test] +fn empty_slot_advance_preserves_parent_lookahead() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 31); // anchor at epoch-0's last slot + + // Give the anchor its own epoch entry with a recognizable lookahead — + // rolled, mutated through the held writer, committed (append-only). + // The anchor bundle is rebuilt with the new epoch id and re-installed + // as the head (the fork-choice anchor node keeps the lazy bundle — + // this test never reads it). + let anchor_epoch_idx = { + let mut g = tile.state.write(); + let mut w = g.epoch.roll_inheriting(tile.last_applied.epoch_idx); + let es = w.state_mut(); + for i in 0..PROPOSER_LOOKAHEAD_SIZE { + es.proposer_lookahead[i] = i as u64; + } + w.commit() + }; + tile.last_applied.epoch_idx = Some(anchor_epoch_idx); + let before = tile.state.state().epoch.view(anchor_epoch_idx).state().proposer_lookahead; + + // Advance across the epoch 0 -> 1 boundary on empty slots. + tile.on_slot_start(32); + assert_eq!(tile.head_state_slot(), 32); + + // Head forked onto a private epoch delta; the anchor's is untouched. + let head_epoch_idx = tile.last_applied.epoch_idx.unwrap(); + assert_ne!(head_epoch_idx, anchor_epoch_idx, "head must COW its epoch delta"); + let after = tile.state.state().epoch.view(anchor_epoch_idx).state().proposer_lookahead; + assert_eq!(before, after, "parent proposer_lookahead shifted in place"); +} + +#[test] +fn slot_advance_crosses_two_epoch_boundaries() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 30); + tile.on_slot_start(66); + assert_eq!(tile.head_state_slot(), 66); +} + +#[test] +fn block_unknown_parent_rejected() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 10); + + // Minimal SignedBeaconBlock: message at fixed offset 100 (4-byte + // offset + 96-byte signature), parent_root @ 116 set to an unknown + // root so precheck bails with ParentMissing before any state change. + let mut buf = vec![0u8; 200]; + buf[100..108].copy_from_slice(&11u64.to_le_bytes()); // slot + buf[108..116].copy_from_slice(&0u64.to_le_bytes()); // proposer_index + buf[116] = 0xFF; // parent_root[0] + + let head_before = tile.last_applied; + let nodes_before = tile.fork_choice.nodes.len(); + tile.apply_and_publish(&buf, true, false, |_block_root| {}); + + assert_eq!(tile.last_applied, head_before, "head must be unchanged"); + assert_eq!(tile.fork_choice.nodes.len(), nodes_before, "no node added"); +} + +// ── pending-block bounds ── + +#[test] +fn pending_admission_window_bounds() { + let mut tile = make_tile_at_wall_slot(50); + seed_tile(&mut tile, 4, 10); + let fin = tile.head_finalized_checkpoint().epoch * SLOTS_PER_EPOCH; + let tol = tile.pending_bounds.future_tolerance; + // At/below the finalized boundary: rejected. + assert!(!tile.within_pending_window(fin)); + // Above finalized and within the future tolerance: admitted. + assert!(tile.within_pending_window(fin + 1)); + assert!(tile.within_pending_window(50 + tol)); + // Beyond the future tolerance: rejected. + assert!(!tile.within_pending_window(50 + tol + 1)); +} + +/// Tile (seed separately) plus a spine + adapter, so tests can drive +/// `buffer_orphan`, which produces into `adapter.producers`. The spine is +/// returned to keep it alive for the adapter. +fn tile_with_producers( + wall_slot: u64, +) -> (BeaconStateTile, TProducer, Box, SpineAdapter) { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let (tile, gp) = make_tile_with_gossip(wall_slot); + let base = std::env::temp_dir().join(format!( + "silver-pending-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&base).expect("temp base"); + let mut spine = Box::new(SilverSpine::new_with_base_dir(&base, None)); + let adapter = SpineAdapter::connect_tile(&tile, &mut spine); + (tile, gp, spine, adapter) +} + +fn root_with(idx: u64, tag: u8) -> B256 { + let mut r = [0u8; 32]; + r[..8].copy_from_slice(&idx.to_le_bytes()); + r[31] = tag; + r +} + +/// Buffer an orphan under a distinct missing parent `idx`, just ahead of +/// the head so the slot-distance fallback stays clear. Its own root is in a +/// separate tag namespace so it never collides with another entry. +fn buffer_orphan_idx( + tile: &mut BeaconStateTile, + gp: &mut TProducer, + producers: &mut Producers, + idx: u64, +) { + let parent = root_with(idx, 0x00); + let block_root = root_with(idx, 0xFF); + let slot = tile.head_state_slot() + 1; + tile.buffer_orphan(parent, block_root, gossip_pending(gp, slot), slot, 0, producers); +} + +#[test] +fn orphan_below_cap_is_buffered() { + let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); + seed_tile(&mut tile, 4, 10); // Following, finalized epoch 0 + let cap = tile.pending_bounds.max_parents; + for i in 0..cap as u64 - 1 { + buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, i); + } + assert_eq!(tile.pending_blocks.len(), cap - 1); + // A new distinct missing parent while below the cap is buffered. + buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, u64::MAX); + assert_eq!(tile.pending_blocks.len(), cap, "orphan buffered below cap"); +} + +#[test] +fn orphan_at_cap_is_refused() { + let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); + seed_tile(&mut tile, 4, 10); + let cap = tile.pending_bounds.max_parents; + for i in 0..cap as u64 { + buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, i); + } + assert_eq!(tile.pending_blocks.len(), cap); + // At the cap, a new distinct missing parent is refused — chain capped. + buffer_orphan_idx(&mut tile, &mut gp, &mut adapter.producers, u64::MAX); + assert_eq!(tile.pending_blocks.len(), cap, "orphan refused at cap"); +} + +#[test] +fn orphan_too_far_ahead_falls_back_to_range_sync() { + let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); + seed_tile(&mut tile, 4, 10); // Following, head slot 10 + let head = tile.head_state_slot(); + let limit = tile.pending_bounds.max_chain_len as u64; + + // At the edge of the gap: still buffered (by-root backtrack). + let edge = head + limit; + tile.buffer_orphan( + root_with(0, 0x00), + root_with(0, 0xFF), + gossip_pending(&mut gp, edge), + edge, + 0, + &mut adapter.producers, + ); + assert_eq!(tile.pending_blocks.len(), 1, "edge orphan buffered"); + + // One slot past the gap: refused before insert, range sync takes over. + let beyond = head + limit + 1; + tile.buffer_orphan( + root_with(1, 0x00), + root_with(1, 0xFF), + gossip_pending(&mut gp, beyond), + beyond, + 0, + &mut adapter.producers, + ); + assert_eq!(tile.pending_blocks.len(), 1, "too-far orphan not buffered"); +} + +#[test] +fn duplicate_orphan_not_rebuffered() { + let (mut tile, mut gp, _spine, mut adapter) = tile_with_producers(200); + seed_tile(&mut tile, 4, 10); + let (parent, block_root) = (root_with(0, 0x00), root_with(0, 0xFF)); + let slot = tile.head_state_slot() + 1; + let buffer = |tile: &mut BeaconStateTile, gp: &mut TProducer, prods: &mut Producers| { + tile.buffer_orphan(parent, block_root, gossip_pending(gp, slot), slot, 0, prods); + }; + buffer(&mut tile, &mut gp, &mut adapter.producers); + buffer(&mut tile, &mut gp, &mut adapter.producers); + assert_eq!(tile.pending_blocks.len(), 1, "same parent"); + assert_eq!(tile.pending_blocks[&parent].len(), 1, "duplicate block_root dropped"); +} + +// ── gossip handlers ── + +#[test] +fn attestation_too_short_ignored() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 10); + let buf = [0u8; 100]; + tile.handle_attestation(&buf, 0); + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, 0); +} + +#[test] +fn ve_unknown_validator_ignored() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let mut buf = [0u8; SIGNED_VOLUNTARY_EXIT_SIZE]; + buf[8..16].copy_from_slice(&999u64.to_le_bytes()); + assert_eq!(tile.handle_voluntary_exit(&buf), Feedback::Ignore); +} + +#[test] +fn ve_accept() { + let mut tile = make_tile(); + // Past shard-committee-period so the exit is permitted. + seed_tile_with_keys(&mut tile, 4, 256 * SLOTS_PER_EPOCH); + let imm = seed_immutable(&tile); + let buf = test_signing::sign_voluntary_exit(0, 0, 0, &imm); + assert_eq!(tile.handle_voluntary_exit(&buf), Feedback::Accept(None)); +} + +#[test] +fn ps_identical_headers_rejected() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let buf = [0u8; PROPOSER_SLASHING_SIZE]; + assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Reject(None)); +} + +#[test] +fn ps_unknown_proposer_ignored() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let mut buf = [0u8; PROPOSER_SLASHING_SIZE]; + buf[8..16].copy_from_slice(&999u64.to_le_bytes()); + buf[216..224].copy_from_slice(&999u64.to_le_bytes()); + buf[208 + 80] = 0xFF; // distinct body_root in h2 + assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Ignore); +} + +#[test] +fn ps_accept() { + let mut tile = make_tile(); + seed_tile_with_keys(&mut tile, 4, 0); + let imm = seed_immutable(&tile); + let buf = test_signing::sign_proposer_slashing(0, 0, 0, &imm); + assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Accept(None)); +} + +#[test] +fn ps_mismatched_slot_rejected() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let mut buf = [0u8; PROPOSER_SLASHING_SIZE]; + buf[208] = 1; // h2.slot differs + assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Reject(None)); +} + +#[test] +fn ps_mismatched_proposer_rejected() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let mut buf = [0u8; PROPOSER_SLASHING_SIZE]; + buf[208 + 8] = 1; // h2.proposer_index differs + assert_eq!(tile.handle_proposer_slashing(&buf), Feedback::Reject(None)); +} + +/// IndexedAttestation with `attesting_indices = indices`, zero sig — for +/// structural / state-derived reject tests only. +fn build_ia_with_indices(target_epoch: u64, bbr_marker: u8, indices: &[u64]) -> Vec { + let mut buf = vec![0u8; 228 + indices.len() * 8]; + buf[0..4].copy_from_slice(&228u32.to_le_bytes()); + buf[20] = bbr_marker; + buf[92..100].copy_from_slice(&target_epoch.to_le_bytes()); + for (i, &vi) in indices.iter().enumerate() { + buf[228 + i * 8..228 + (i + 1) * 8].copy_from_slice(&vi.to_le_bytes()); + } + buf +} + +fn wrap_attester_slashing(ia1: &[u8], ia2: &[u8]) -> Vec { + let off1: u32 = 8; + let off2: u32 = off1 + ia1.len() as u32; + let mut buf = Vec::with_capacity(8 + ia1.len() + ia2.len()); + buf.extend_from_slice(&off1.to_le_bytes()); + buf.extend_from_slice(&off2.to_le_bytes()); + buf.extend_from_slice(ia1); + buf.extend_from_slice(ia2); + buf +} + +#[test] +fn as_zero_intersection_rejected() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let ia1 = build_ia_with_indices(0, 0xAA, &[0]); + let ia2 = build_ia_with_indices(0, 0xBB, &[1]); + let buf = wrap_attester_slashing(&ia1, &ia2); + assert_eq!(tile.handle_attester_slashing(&buf), Feedback::Reject(None)); +} + +#[test] +fn as_accept() { + let mut tile = make_tile(); + seed_tile_with_keys(&mut tile, 4, 0); + let imm = seed_immutable(&tile); + let buf = test_signing::sign_attester_slashing_double_vote(0, 0, 0, 0, &imm); + assert_eq!(tile.handle_attester_slashing(&buf), Feedback::Accept(None)); +} + +#[test] +fn as_zero_intersection_with_valid_sigs_rejected() { + let mut tile = make_tile(); + seed_tile_with_keys(&mut tile, 4, 0); + let imm = seed_immutable(&tile); + let ia1 = test_signing::build_indexed_attestation(0, 0, 0, 0, 0, 0xAA, &imm); + let ia2 = test_signing::build_indexed_attestation(1, 1, 0, 0, 0, 0xBB, &imm); + let buf = wrap_attester_slashing(&ia1, &ia2); + assert_eq!(tile.handle_attester_slashing(&buf), Feedback::Reject(None)); +} + +#[test] +fn bls_change_unknown_validator_ignored() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let mut buf = [0u8; SIGNED_BLS_CHANGE_SIZE]; + buf[0..8].copy_from_slice(&999u64.to_le_bytes()); + assert_eq!(tile.handle_bls_to_execution_change(&buf), Feedback::Ignore); +} + +#[test] +fn bls_change_wrong_prefix_rejected() { + let mut tile = make_tile(); + // Validator 0 has ETH1-prefixed credentials in the base; the + // canonical head (anchor over the base) then rejects the change. + let mut eth1 = Withdrawals([0u8; 32]); + eth1.0[0] = 0x01; + let seeds: Vec = (0..4) + .map(|i| ValSeed { + pubkey: placeholder_pubkey(i), + withdrawal_credentials: if i == 0 { eth1 } else { Withdrawals::default() }, + effective_balance: MAX_EFFECTIVE_BALANCE, + balance: MAX_EFFECTIVE_BALANCE, + activation_epoch: 0, + ..Default::default() + }) + .collect(); + let cp = Checkpoint { epoch: 0, root: ANCHOR_ROOT }; + let epoch_base = epoch_base_with(cp, cp); + arm_tile(&mut tile, epoch_base, &seeds, 0); + let buf = [0u8; SIGNED_BLS_CHANGE_SIZE]; // vi = 0 + assert_eq!(tile.handle_bls_to_execution_change(&buf), Feedback::Reject(None)); +} + +#[test] +fn bls_change_accept() { + let mut tile = make_tile(); + seed_tile_with_keys(&mut tile, 4, 0); + let imm = seed_immutable(&tile); + let to_addr = [0x42u8; 20]; + let buf = test_signing::sign_bls_to_execution_change(0, 0, &to_addr, &imm); + assert_eq!(tile.handle_bls_to_execution_change(&buf), Feedback::Accept(None)); +} + +#[test] +fn block_known_parent_bad_sig_rejected() { + let mut tile = make_tile(); + seed_tile(&mut tile, 128, 10); + + // Known latest_block_header → derive a realistic parent_root and + // anchor fork choice on it. + let genesis_header = BeaconBlockHeader { + slot: 10, + proposer_index: 0, + parent_root: [0u8; 32], + state_root: [0x01; 32], + body_root: [0u8; 32], + }; + { + // Roll a fresh slot fork off the anchor with the known header set + // on the writer, commit, and repoint the head bundle's slot index + // — append-only, no re-open of the published anchor fork. + let new_slot_idx = { + let mut g = tile.state.write(); + let mut sw = g.slot_states.roll_from(tile.last_applied.slot_idx); + sw.state_mut().latest_block_header = genesis_header; + sw.commit() + }; + tile.last_applied.slot_idx = new_slot_idx; + } + let parent_root = ssz_hash::hash_tree_root_block_header(&genesis_header); + + let cp = Checkpoint { epoch: 0, root: parent_root }; + tile.fork_choice = + ForkChoice::init(cp, cp, 10, parent_root, [0u8; 32], false, tile.last_applied, 0); + + // Valid structure, zeroed BLS signature → precheck reaches and fails + // signature verification, so no fork-choice node is added. + let mut buf = vec![0u8; 200]; + buf[100..108].copy_from_slice(&11u64.to_le_bytes()); // slot + buf[108..116].copy_from_slice(&0u64.to_le_bytes()); // proposer_index + buf[116..148].copy_from_slice(&parent_root); // parent_root + + tile.apply_and_publish(&buf, true, false, |_block_root| {}); + assert_eq!(tile.fork_choice.nodes.len(), 1); +} + +// ── attestation / aggregate (committee resolution via shuffling cache) ── + +/// Locate `(slot, committee_index, pos_in_committee, committee_size)` for +/// validator 0 in epoch 0. The seed arms exactly one cache entry per epoch. +fn find_committee_for_vi0(tile: &BeaconStateTile) -> (Slot, usize, usize, usize) { + let shuffled = tile.shuffling_cache.shuffled_by_epoch(0).expect("shuffling for epoch 0"); + let shuffling = stf::EpochShuffling::new(shuffled, tile.head_validator_count()); + for s in 0..SLOTS_PER_EPOCH { + for ci in 0..shuffling.committees_per_slot { + let c = shuffling.committee(s, ci); + if let Some(pos) = c.iter().position(|&v| v == 0) { + return (s, ci, pos, c.len()); + } + } + } + panic!("validator 0 in some committee") +} + +/// Spec `compute_subnet_for_attestation`, recomputed independently of the +/// production helper. +fn expected_subnet(tile: &BeaconStateTile, slot: Slot, ci: usize) -> u64 { + let shuffled = tile + .shuffling_cache + .shuffled_by_epoch(slot / SLOTS_PER_EPOCH) + .expect("shuffling for epoch"); + let cps = stf::EpochShuffling::new(shuffled, tile.head_validator_count()).committees_per_slot; + (cps as u64 * (slot % SLOTS_PER_EPOCH) + ci as u64) % 64 +} + +fn build_agg_for_vi0(tile: &BeaconStateTile) -> Vec { + let imm = seed_immutable(tile); + let beacon_block_root = tile.last_applied_block_root; + let target_root = tile.last_applied_block_root; + let (slot, ci, pos, csize) = find_committee_for_vi0(tile); + test_signing::sign_aggregate_and_proof( + 0, + 0, + slot, + slot / SLOTS_PER_EPOCH, + beacon_block_root, + target_root, + ci, + pos, + csize, + &imm, + ) +} + +#[test] +fn attestation_updates_vote_tracker() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + // Vote for the (known) anchor block; target is the anchor's checkpoint + // block, so the spec target/ancestor checks accept. + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + // Assert against what the handler reads via the view (the view owns + // the offsets), verifying the vote fold self-consistently. + let want_root = *SingleAttestationView::beacon_block_root(&buf); + let want_epoch = SingleAttestationView::target_epoch(&buf); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, want_root); + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, want_epoch); +} + +/// Spec `validate_on_attestation`: a single attestation for a block we +/// don't hold is dropped (Ignore), self-healing on the validator's next +/// vote. +#[test] +fn single_att_unknown_block_ignored() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let unknown = [0xAAu8; 32]; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + unknown, + slot / SLOTS_PER_EPOCH, + unknown, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); +} + +/// Spec `validate_on_attestation`: a known-block vote whose target does not +/// match the block's target-epoch ancestor is rejected. +#[test] +fn single_att_mismatched_target_rejected() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; // known anchor + let wrong_target = [0x77u8; 32]; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + wrong_target, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Reject(None)); +} + +/// Fulu: a single attestation with a non-zero `AttestationData.index` is +/// rejected (the committee belongs in `committee_index`). Checked before +/// signature verification, so a zero-signed buffer suffices. +#[test] +fn single_att_nonzero_data_index_rejected() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let mut buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + // AttestationData.index @ buf[24..32]; non-zero is illegal post-Electra. + buf[24] = 1; + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Reject(None)); +} + +/// Spec `validate_on_attestation`: a current-slot vote is held until the +/// next slot. It is accepted but not folded until `drain_pending_votes`. +#[test] +fn current_slot_vote_deferred_until_drain() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + // Make the committee slot the current slot → the vote must defer. + tile.ticker.set_current_slot(slot); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + // Deferred: not yet folded into the tracker. + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, [0u8; 32]); + let n = tile.head_validator_count(); + tile.fork_choice.drain_pending_votes(n); + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, bbr); +} + +/// Spec [REJECT]: an attestation must arrive on the subnet its committee +/// maps to. +#[test] +fn single_att_wrong_subnet_rejected() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, (subnet + 1) % 64), Feedback::Reject(None)); + // The reject must not have marked the attester seen. + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); +} + +/// Spec [IGNORE]: at most one attestation per (attester, target epoch) — +/// byte-identical or not — and the ignored resend never reaches the pool. +#[test] +fn single_att_repeat_attester_epoch_ignored() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let mut buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + let data_root = ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + let first = tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).unwrap(); + + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); + assert_eq!(tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).unwrap(), first); + + // Same attester+epoch, different source epoch (the one AttestationData + // field the handler doesn't validate): first-seen keys on the pair, + // not the content, so the variant must not open a new pool entry. + buf[64..72].copy_from_slice(&1u64.to_le_bytes()); + test_signing::resign_single_attestation(0, &mut buf, &imm); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Ignore); + let new_root = ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + assert_eq!(tile.attestation_pool.aggregate_ssz(slot, ci as u64, new_root), None); +} + +/// A rejected attestation must not mark the attester seen, or a forged +/// message would censor the validator's honest vote for the epoch. +#[test] +fn single_att_failed_validation_does_not_mark_seen() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, _, _) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + // Signed with sk 1; validator 0's registry key is pubkey_pk(0). + let bad = test_signing::sign_single_attestation( + 1, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&bad, subnet), Feedback::Reject(None)); + + let honest = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&honest, subnet), Feedback::Accept(None)); +} + +/// An accepted single attestation lands in the pool: participant bit at +/// the attester's committee position, bitlist sized to the real committee, +/// data bytes carried over verbatim. +#[test] +fn single_att_accept_inserts_into_pool() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let (slot, ci, pos, csize) = find_committee_for_vi0(&tile); + let subnet = expected_subnet(&tile, slot, ci); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + 0, + 0, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + + let data_root = ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + let out = + tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).expect("pooled aggregate"); + let bits = AttestationView::aggregation_bits(&out); + assert!(bits[pos / 8] & (1 << (pos % 8)) != 0); + assert_eq!(merkle::bitlist_len(bits), csize); + assert_eq!( + AttestationView::data(&out).as_bytes(), + SingleAttestationView::data(&buf).as_bytes() + ); +} + +/// Marking a validator equivocating zeroes its live vote and blocks future +/// votes (spec `equivocating_indices` exclusion). +#[test] +fn equivocator_excluded_from_votes() { + let mut tile = make_tile(); + seed_tile(&mut tile, 8, 0); + let anchor = tile.last_applied_block_root; + let n = tile.head_validator_count(); + tile.fork_choice.record_vote( + &AttestationVote { + validator: 3, + block_root: anchor, + target_epoch: 0, + attestation_slot: 0, + payload_present: false, + }, + n, + ); + assert_eq!(tile.fork_choice.vote_tracker.votes[3].latest_root, anchor); + + tile.fork_choice.mark_equivocating(3); + assert!(tile.fork_choice.is_equivocating(3)); + assert_eq!(tile.fork_choice.vote_tracker.votes[3].latest_root, [0u8; 32]); + + // A later attestation from an equivocator is ignored. + tile.fork_choice.record_vote( + &AttestationVote { + validator: 3, + block_root: [0x55u8; 32], + target_epoch: 5, + attestation_slot: 5, + payload_present: false, + }, + n, + ); + assert_eq!(tile.fork_choice.vote_tracker.votes[3].latest_root, [0u8; 32]); +} + +/// The justified-balance snapshot is rebuilt only when the justified +/// checkpoint moves; the first build is a full pass, the next is a no-op. +#[test] +fn justified_balances_rebuilt_on_checkpoint_change_only() { + let mut tile = make_tile(); + seed_tile(&mut tile, 8, 0); + // Anchor justified checkpoint differs from the default → stale → rebuild. + assert!(tile.fork_choice.justified_balances_stale()); + tile.refresh_justified_balances(); + assert!(!tile.fork_choice.justified_balances_stale()); + assert_eq!(tile.fork_choice.justified_balances.len(), 8); + assert!(tile.fork_choice.justified_balances.iter().all(|&b| b == MAX_EFFECTIVE_BALANCE)); + // Total active balance is cached in the same sweep: all 8 active and + // unslashed → 8 × MAX_EFFECTIVE_BALANCE (proposer boost reads this + // instead of re-sweeping per block). + assert_eq!(tile.fork_choice.justified_total_active_balance(), 8 * MAX_EFFECTIVE_BALANCE); + // Unchanged checkpoint → no rebuild (idempotent). + tile.refresh_justified_balances(); +} + +#[test] +fn agg_multi_committee_bits_rejected() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let mut buf = vec![0u8; SIGNED_AGG_PROOF_MIN]; + buf[436] = 0b0000_0011; // two committee bits + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); +} + +#[test] +fn agg_unknown_block_root_ignored() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let mut buf = vec![0u8; SIGNED_AGG_PROOF_MIN]; + buf[436] = 0b0000_0001; // single committee bit + buf[228] = 0xFF; // beacon_block_root not in fork choice + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); +} + +#[test] +fn agg_accept() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let buf = build_agg_for_vi0(&tile); + let beacon_block_root = tile.last_applied_block_root; + let slot = SignedAggregateAndProofView::agg_slot(&buf); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, beacon_block_root); + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, slot / SLOTS_PER_EPOCH); +} + +#[test] +fn agg_respects_epoch_monotonicity() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + + let preset_root = [0x99u8; 32]; + tile.fork_choice.vote_tracker.votes[0].latest_root = preset_root; + tile.fork_choice.vote_tracker.votes[0].latest_epoch = 1; + + let buf = build_agg_for_vi0(&tile); + assert_eq!(SignedAggregateAndProofView::agg_target_epoch(&buf), 0); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); + + // Older-epoch aggregate must not overwrite the newer vote. + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_root, preset_root); + assert_eq!(tile.fork_choice.vote_tracker.votes[0].latest_epoch, 1); +} + +#[test] +fn agg_slot_too_old_ignored() { + let mut tile = make_tile_at_wall_slot(100); + seed_tile_with_keys(&mut tile, 128, 0); + let buf = build_agg_for_vi0(&tile); + assert!(SignedAggregateAndProofView::agg_slot(&buf) < 100 - ATTESTATION_PROPAGATION_SLOT_RANGE); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); +} + +#[test] +fn agg_slot_too_future_ignored() { + let mut tile = make_tile_at_wall_slot(0); + seed_tile(&mut tile, 128, 0); + let mut buf = vec![0u8; SIGNED_AGG_PROOF_MIN]; + buf[436] = 0b0000_0001; + buf[212] = 5; // slot = 5 > wall (0) + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); +} + +#[test] +fn agg_committee_index_oor_rejected() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let mut buf = build_agg_for_vi0(&tile); + for i in 0..8 { + buf[436 + i] = 0; + } + buf[436] = 0b0000_0010; // committee_index 1, OOR for committees_per_slot=1 + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); +} + +#[test] +fn agg_is_aggregator_false_rejected() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 1024, 0); + let mut buf = build_agg_for_vi0(&tile); + let (_, _, _, csize) = find_committee_for_vi0(&tile); + assert_eq!(csize, 32, "committee_len drives the aggregator modulo"); + + let sp_off = 112usize; + let mut sig_arr: [u8; 96] = buf[sp_off..sp_off + 96].try_into().unwrap(); + let mut b: u16 = 0; + loop { + sig_arr[0] = b as u8; + if !gossip::is_aggregator(csize, &sig_arr) { + break; + } + b += 1; + assert!(b < 256, "no parity-flipping byte found (impossible)"); + } + buf[sp_off..sp_off + 96].copy_from_slice(&sig_arr); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Reject(None)); +} + +/// Spec [IGNORE]: at most one aggregate per (aggregator, target epoch) — +/// a byte-identical resend dies on the seen probe. +#[test] +fn agg_repeat_aggregator_epoch_ignored() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let buf = build_agg_for_vi0(&tile); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Ignore); +} + +/// A rejected aggregate must not mark the aggregator seen, or a forged +/// message would censor the aggregator's real aggregate for the epoch. +#[test] +fn agg_failed_validation_does_not_mark_aggregator() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let buf = build_agg_for_vi0(&tile); + let mut forged = buf.clone(); + forged[50] ^= 0xFF; // outer signature = buf[4..100) + assert_eq!(tile.handle_aggregate_and_proof(&forged), Feedback::Reject(None)); + assert_eq!(tile.handle_aggregate_and_proof(&buf), Feedback::Accept(None)); +} + +/// First committee (skipping the wall slot) holding two members whose +/// registry keys differ (vi % 3), so the aggregate is genuinely multi-key. +fn find_committee_with_two_signers(tile: &BeaconStateTile) -> (Slot, usize, u32, u32) { + let shuffled = tile.shuffling_cache.shuffled_by_epoch(0).expect("shuffling for epoch 0"); + let shuffling = stf::EpochShuffling::new(shuffled, tile.head_validator_count()); + for s in 0..SLOTS_PER_EPOCH - 1 { + for ci in 0..shuffling.committees_per_slot { + let c = shuffling.committee(s, ci); + for (i, &a) in c.iter().enumerate() { + if let Some(&b) = c[i + 1..].iter().find(|&&b| b % 3 != a % 3) { + return (s, ci, a, b); + } + } + } + } + panic!("two distinct-key members in some committee") +} + +fn committee_of(tile: &BeaconStateTile, slot: Slot, ci: usize) -> Vec { + let shuffled = tile + .shuffling_cache + .shuffled_by_epoch(slot / SLOTS_PER_EPOCH) + .expect("shuffling for epoch"); + stf::EpochShuffling::new(shuffled, tile.head_validator_count()).committee(slot, ci).to_vec() +} + +/// Wrap an inner aggregate with `vi` as aggregator (registry keys cycle +/// `vi % 3`). +fn wrap_by(imm: &Immutable, vi: u32, aggregate: &[u8]) -> Vec { + test_signing::wrap_aggregate_and_proof(vi as usize % 3, vi as u64, aggregate, imm) +} + +/// Sign `vi`'s single attestation for the anchor at `(slot, ci)`, feed it +/// through `handle_attestation`, and return the grown pooled aggregate. +fn pool_single_then_aggregate( + tile: &mut BeaconStateTile, + vi: u32, + slot: Slot, + ci: usize, +) -> Vec { + let imm = seed_immutable(tile); + let bbr = tile.last_applied_block_root; + let buf = test_signing::sign_single_attestation( + vi as usize % 3, + vi as u64, + ci as u64, + slot, + bbr, + slot / SLOTS_PER_EPOCH, + bbr, + &imm, + ); + let data_root = ssz_hash::hash_attestation_data(SingleAttestationView::data(&buf).as_bytes()); + let subnet = expected_subnet(tile, slot, ci); + assert_eq!(tile.handle_attestation(&buf, subnet), Feedback::Accept(None)); + tile.attestation_pool.aggregate_ssz(slot, ci as u64, data_root).expect("pooled aggregate") +} + +#[test] +fn pool_aggregate_accepted_by_aggregate_and_proof_path() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + let aggregate = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + + // Committee of 4 < 16 ⇒ any member trivially passes is_aggregator. + let wrapped = wrap_by(&imm, vi_a, &aggregate); + // Accept requires verify_aggregate_and_proof_sigs: selection proof, + // outer signature, and the pooled aggregate signature against the two + // participants' aggregated registry pubkeys. + assert_eq!(tile.handle_aggregate_and_proof(&wrapped), Feedback::Accept(None)); +} + +/// First-seen keys on (aggregator, target epoch), not message bytes: a +/// second, different-but-valid aggregate from the same aggregator is +/// still ignored. +#[test] +fn agg_repeat_keys_on_aggregator_not_bytes() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_one)), + Feedback::Accept(None) + ); + + // A second participant grows the pooled aggregate: different bytes, + // fully valid, same (aggregator, target epoch). + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_ne!(agg_two, agg_one); + assert_eq!(tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), Feedback::Ignore); +} + +/// Neither admission rule keys on the attestation data alone: aggregates +/// over the same data from two distinct aggregators both accept, provided +/// the second grows bit coverage (equal bits die on the superset rule). +#[test] +fn agg_distinct_aggregators_same_data_both_accept() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_one)), + Feedback::Accept(None) + ); + + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_two)), + Feedback::Accept(None) + ); +} + +/// Spec [IGNORE]: bits ⊆ an already-seen valid aggregate's — equal or +/// strictly smaller — die on the coverage probe regardless of who +/// aggregated them. +#[test] +fn agg_subset_from_other_aggregator_ignored() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), + Feedback::Accept(None) + ); + + // Both from an aggregator the epoch has not seen, so only the + // coverage rule can be what ignores them. + assert_eq!(tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_two)), Feedback::Ignore); + assert_eq!(tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_b, &agg_one)), Feedback::Ignore); +} + +/// The superset gate fires before signature verification: a covered +/// message with a corrupted outer signature probes Ignore instead of +/// reaching the Reject the signature would earn. Skipping that ~1 ms +/// batch verify is the point of the coverage rule. +#[test] +fn agg_superset_gate_precedes_signature_verify() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + + let agg_one = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + let agg_two = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_two)), + Feedback::Accept(None) + ); + + let mut forged = wrap_by(&imm, vi_b, &agg_one); + forged[50] ^= 0xFF; // outer signature = buf[4..100) + assert_eq!(tile.handle_aggregate_and_proof(&forged), Feedback::Ignore); +} + +/// Union-covered bits (inside the OR of seen patterns, ⊆ none singly) +/// must still verify and relay: union coverage sheds only the local vote +/// fold, never forwarding. +#[test] +fn agg_union_covered_still_relays() { + let mut tile = make_tile_at_wall_slot(31); + seed_tile_with_keys(&mut tile, 128, 0); + let imm = seed_immutable(&tile); + let bbr = tile.last_applied_block_root; + let (slot, ci, vi_a, vi_b) = find_committee_with_two_signers(&tile); + let committee = committee_of(&tile, slot, ci); + let vi_c = *committee.iter().find(|&&v| v != vi_a && v != vi_b).expect("committee of 4"); + let pos_b = committee.iter().position(|&v| v == vi_b).unwrap(); + + // {a} from aggregator a, then {b} alone from aggregator b: disjoint + // patterns whose union is {a, b}. + let agg_a = pool_single_then_aggregate(&mut tile, vi_a, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_a, &agg_a)), + Feedback::Accept(None) + ); + let agg_b_only = test_signing::sign_aggregate_and_proof( + vi_b as usize % 3, + vi_b as u64, + slot, + slot / SLOTS_PER_EPOCH, + bbr, + bbr, + ci, + pos_b, + committee.len(), + &imm, + ); + assert_eq!(tile.handle_aggregate_and_proof(&agg_b_only), Feedback::Accept(None)); + + // {a, b} from a third aggregator: within the union, inside neither. + let agg_ab = pool_single_then_aggregate(&mut tile, vi_b, slot, ci); + assert_eq!( + tile.handle_aggregate_and_proof(&wrap_by(&imm, vi_c, &agg_ab)), + Feedback::Accept(None) + ); +} + +// ── finalization (deposit append lands on the delta, not the base) ── + +/// A `PendingDeposit` for a brand-new validator (spec key 0), signed under +/// the genesis deposit domain (fork version + gvr both zero). +fn signed_new_validator_deposit() -> (BLSPubkey, PendingDeposit) { + const DOMAIN_DEPOSIT: u32 = 0x03; + let pk = test_signing::pubkey_bytes(0); + let wc = Withdrawals([0xAAu8; 32]); + let amount = 32_000_000_000u64; + + let msg_root = + merkle::merkleize(&[merkle::hash_fixed_bytes(&pk), wc.0, merkle::uint64_chunk(amount)]); + let domain = bls::compute_domain(DOMAIN_DEPOSIT, [0; 4], &[0u8; 32]); + let signing_root = bls::compute_signing_root(&msg_root, &domain); + let sig = test_signing::sign(0, &signing_root); + + (pk, PendingDeposit { pubkey: pk, withdrawal_credentials: wc, amount, signature: sig, slot: 0 }) +} + +/// Speculative validator appends from deposit processing must land on the +/// head fork's `validators` delta, not on the shared finalized base — +/// the base only advances at Casper finality. +#[test] +fn epoch_transition_keeps_base_until_finality() { + let mut tile = make_tile(); + // Arm with epoch 1 already finalized in the base (finality lives in the + // base, so this is equivalent to advancing it — and avoids mutating the + // published base mid-test) so slot-0 deposits are eligible. + let (_eb, seeds) = build_seed_finalized(1, false); + let epoch_base = epoch_base_with(Checkpoint { epoch: 0, root: ANCHOR_ROOT }, Checkpoint { + epoch: 1, + root: ANCHOR_ROOT, + }); + arm_tile(&mut tile, epoch_base, &seeds, 0); + + let (pk, deposit) = signed_new_validator_deposit(); + // Queue the deposit append-only: roll a fresh pending fork off the + // head, push, and repoint the head bundle at the committed id. + tile.last_applied.pending_idx = { + let mut g = tile.state.write(); + let mut pw = g.pending.roll_from(tile.last_applied.pending_idx); + pw.deposits.push(deposit); + pw.commit() + }; + + tile.on_slot_start(SLOTS_PER_EPOCH); + + // Base unchanged; the new validator lives on the head fork's delta. + assert_eq!( + tile.state.state().validators.finalized().validator_count(), + 1, + "base must wait for finality" + ); + let head_validators = tile.state.state().validators.view(tile.last_applied.validators_idx); + assert_eq!(head_validators.count(), 2); + assert_eq!(*head_validators.pubkey(1), pk); +} + +/// `maybe_finalize` with `fin_idx > 0` must (a) promote the finalized +/// delta into the base, (b) prune non-descendant siblings from fork +/// choice, (c) re-base the surviving descendant's cumulative edits against +/// the new base, and (d) hand the survivor its re-anchored bundle. This is +/// the only place the survivor re-base path is exercised — single-fork +/// tests take the no-promote branch (`fin_idx == 0`). +#[test] +fn multi_fork_finalize_promotes_and_rebases() { + let mut tile = make_tile(); + seed_tile(&mut tile, 4, 0); + let anchor_id = tile.last_applied; + + const F_ROOT: B256 = [0x0F; 32]; + const D_ROOT: B256 = [0x0D; 32]; + const F2_ROOT: B256 = [0xF2; 32]; + const ZERO_CP: Checkpoint = Checkpoint { epoch: 0, root: [0u8; 32] }; + let f_cp = Checkpoint { epoch: 0, root: F_ROOT }; + + // Roll a slot-group fork off `parent` with a slot number + one + // cumulative block root set on the writer, then commit — the + // writer→commit path (no in-place re-open). `reset_from` carries the + // parent's root tail, so a child appends onto it. + let roll_slot = |st: &mut BeaconStateOwner, parent: SlotStateId, slot: Slot, root: B256| { + let mut g = st.write(); + let mut sw = g.slot_states.roll_from(parent); + sw.state_mut().slot = slot; + sw.push_block_root(root); + sw.commit() + }; + let roll_balances = |st: &mut BeaconStateOwner, parent| { + let mut g = st.write(); + g.balances.roll_from(parent).commit() + }; + + // F: child of anchor (to be finalized). One cumulative `block_root`. + // Each fork's bundle copies its parent's and re-points the rolled + // tiers (slot + balances here, the others shared for this test). + let f_id = StateId { + balances_idx: roll_balances(&mut tile.state, anchor_id.balances_idx), + slot_idx: roll_slot(&mut tile.state, anchor_id.slot_idx, 1, F_ROOT), + ..anchor_id + }; + + // D: child of F (head, survives). Inherits F's block_roots via + // `reset_from`, appends its own → [F_ROOT, D_ROOT]. + let d_id = StateId { + balances_idx: roll_balances(&mut tile.state, f_id.balances_idx), + slot_idx: roll_slot(&mut tile.state, f_id.slot_idx, 2, D_ROOT), + ..f_id + }; + + // F2: sibling of F (will be pruned by fork choice). + let f2_id = StateId { + balances_idx: roll_balances(&mut tile.state, anchor_id.balances_idx), + slot_idx: roll_slot(&mut tile.state, anchor_id.slot_idx, 1, F2_ROOT), + ..anchor_id + }; + + // Insert F2 first so its idx is below F's — fork choice's `prune` + // only drops the prefix below the finalized node, so the sibling has + // to live ahead of the to-be-finalized node to be reclaimed. + tile.fork_choice.on_block(BlockImport { + slot: 1, + block_root: F2_ROOT, + parent_root: ANCHOR_ROOT, + execution_block_hash: [0u8; 32], + justified: ZERO_CP, + finalized: ZERO_CP, + unrealized_justified: ZERO_CP, + unrealized_finalized: ZERO_CP, + state_id: f2_id, + bid_block_hash: [0u8; 32], + parent_payload_status: PayloadStatus::Full, + payload_verified: true, + is_gloas: false, + }); + tile.fork_choice.on_block(BlockImport { + slot: 1, + block_root: F_ROOT, + parent_root: ANCHOR_ROOT, + execution_block_hash: [0u8; 32], + justified: f_cp, + finalized: f_cp, + unrealized_justified: f_cp, + unrealized_finalized: f_cp, + state_id: f_id, + bid_block_hash: [0u8; 32], + parent_payload_status: PayloadStatus::Full, + payload_verified: true, + is_gloas: false, + }); + tile.fork_choice.on_block(BlockImport { + slot: 2, + block_root: D_ROOT, + parent_root: F_ROOT, + execution_block_hash: [0u8; 32], + justified: f_cp, + finalized: f_cp, + unrealized_justified: f_cp, + unrealized_finalized: f_cp, + state_id: d_id, + bid_block_hash: [0u8; 32], + parent_payload_status: PayloadStatus::Full, + payload_verified: true, + is_gloas: false, + }); + + // Head is D; finality target is F. + tile.last_applied = d_id; + tile.last_applied_block_root = D_ROOT; + tile.fork_choice.finalized_checkpoint = f_cp; + // Republish so the seqlock control matches the new head. + tile.state.publish_state_id(d_id); + + // Sanity: pre-finalize state. + assert_eq!(tile.state.state().slot_states.finalized_view().slot_number(), 0); + let d_slot_view = tile.state.state().slot_states.view(d_id.slot_idx); + assert_eq!(d_slot_view.delta_block_roots(), [F_ROOT, D_ROOT]); + assert!(tile.fork_choice.find_node_idx(&F2_ROOT).is_some()); + + tile.maybe_finalize(); + + // (a) Base advanced to F's slot scalars; F's block_root landed in the + // circular buffer at the old finalized slot offset. + let base = tile.state.state().slot_states.finalized_view(); + assert_eq!(base.slot_number(), 1, "base slot promoted to F's slot"); + assert_eq!(base.finalized_block_roots()[0], F_ROOT, "F's block_root in base circular buffer"); + + // (b) F2 pruned from fork choice; F is now node 0 (anchor); D survives. + assert!(tile.fork_choice.find_node_idx(&F2_ROOT).is_none(), "F2 dropped"); + assert_eq!(tile.fork_choice.find_node_idx(&F_ROOT), Some(0), "F is the new anchor"); + let d_node = tile.fork_choice.find_node_idx(&D_ROOT).expect("D survives"); + + // (c) D's cumulative `block_roots` log re-based against the new base: + // F's prefix drained, only D's incremental entry remains. Finalize + // re-anchored D into a fresh slot fork, so re-read its bundle from + // the fork-choice node. + let d_rebased = tile.fork_choice.node(d_node).state_id; + let d_slot_view = tile.state.state().slot_states.view(d_rebased.slot_idx); + assert_eq!(d_slot_view.delta_block_roots(), [D_ROOT], "D's block_roots drained of F's prefix"); + + // (d) D was the head, so `last_applied` got the same re-anchored + // bundle (not the stale pre-finalize one). + assert_eq!(tile.last_applied, d_rebased, "head bundle refreshed"); + assert_ne!(tile.last_applied, d_id, "stale head bundle replaced"); +} + +// ── fork_digest (standalone `compute_fork_digest`, no tile state) ── + +const FD_SENTINEL: BlobParameters = BlobParameters { epoch: u64::MAX, max_blobs_per_block: 0 }; + +fn fd_genesis_validators_root(b: u8) -> B256 { + [b; 32] +} + +fn fd_ef_schedule() -> [BlobParameters; 6] { + [ + BlobParameters { epoch: 9, max_blobs_per_block: 9 }, + BlobParameters { epoch: 100, max_blobs_per_block: 100 }, + BlobParameters { epoch: 150, max_blobs_per_block: 175 }, + BlobParameters { epoch: 200, max_blobs_per_block: 200 }, + BlobParameters { epoch: 250, max_blobs_per_block: 275 }, + BlobParameters { epoch: 300, max_blobs_per_block: 300 }, + ] +} + +fn fd_ef_digest(epoch: Epoch, fork_version: Version, gvr: &B256) -> [u8; 4] { + let schedule = fd_ef_schedule(); + let bp = get_blob_parameters(epoch, &schedule, FD_SENTINEL); + compute_fork_digest(fork_version, gvr, Some(bp)) +} + +#[test] +fn ef_compute_fork_digest_vectors() { + let v6 = [0x06, 0x00, 0x00, 0x00]; + let v61 = [0x06, 0x00, 0x00, 0x01]; + let v7 = [0x07, 0x00, 0x00, 0x00]; + let v71 = [0x07, 0x00, 0x00, 0x01]; + + let cases: &[(Epoch, Version, B256, [u8; 4])] = &[ + (9, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), + (10, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), + (11, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), + (99, v6, fd_genesis_validators_root(0), [0xab, 0x3a, 0xe6, 0xc8]), + (100, v6, fd_genesis_validators_root(0), [0xdf, 0x67, 0x55, 0x7b]), + (101, v6, fd_genesis_validators_root(0), [0xdf, 0x67, 0x55, 0x7b]), + (150, v6, fd_genesis_validators_root(0), [0x8a, 0xb3, 0x8b, 0x59]), + (199, v6, fd_genesis_validators_root(0), [0x8a, 0xb3, 0x8b, 0x59]), + (200, v6, fd_genesis_validators_root(0), [0xd9, 0xb8, 0x14, 0x38]), + (201, v6, fd_genesis_validators_root(0), [0xd9, 0xb8, 0x14, 0x38]), + (250, v6, fd_genesis_validators_root(0), [0x4e, 0xf3, 0x2a, 0x62]), + (299, v6, fd_genesis_validators_root(0), [0x4e, 0xf3, 0x2a, 0x62]), + (300, v6, fd_genesis_validators_root(0), [0xca, 0x10, 0x0d, 0x64]), + (301, v6, fd_genesis_validators_root(0), [0xca, 0x10, 0x0d, 0x64]), + (9, v6, fd_genesis_validators_root(1), [0x89, 0x67, 0x11, 0x11]), + (9, v6, fd_genesis_validators_root(2), [0xf4, 0x9b, 0x0e, 0x24]), + (9, v6, fd_genesis_validators_root(3), [0x86, 0x54, 0x4e, 0x4f]), + (100, v6, fd_genesis_validators_root(1), [0xfd, 0x3a, 0xa2, 0xa2]), + (100, v6, fd_genesis_validators_root(2), [0x80, 0xc6, 0xbd, 0x97]), + (100, v6, fd_genesis_validators_root(3), [0xf2, 0x09, 0xfd, 0xfc]), + (9, v61, fd_genesis_validators_root(0), [0x30, 0xf8, 0xc2, 0x5b]), + (9, v7, fd_genesis_validators_root(0), [0x04, 0x32, 0xf5, 0xa9]), + (9, v71, fd_genesis_validators_root(0), [0x6e, 0x69, 0xa6, 0x71]), + (100, v61, fd_genesis_validators_root(0), [0x44, 0xa5, 0x71, 0xe8]), + (100, v7, fd_genesis_validators_root(0), [0x70, 0x6f, 0x46, 0x1a]), + (100, v71, fd_genesis_validators_root(0), [0x1a, 0x34, 0x15, 0xc2]), + ]; + + for (epoch, fv, g, expected) in cases { + let got = fd_ef_digest(*epoch, *fv, g); + assert_eq!( + got, *expected, + "epoch={epoch} fv={fv:02x?} gvr[0]={:#04x}: got {got:02x?}, want {expected:02x?}", + g[0] + ); + } +} + +#[test] +fn mainnet_fulu_fork_digest_419072() { + let mainnet_gvr: B256 = [ + 0x4b, 0x36, 0x3d, 0xb9, 0x4e, 0x28, 0x61, 0x20, 0xd7, 0x6e, 0xb9, 0x05, 0x34, 0x0f, 0xdd, + 0x4e, 0x54, 0xbf, 0xe9, 0xf0, 0x6b, 0xf3, 0x3f, 0xf6, 0xcf, 0x5a, 0xd2, 0x7f, 0x51, 0x1b, + 0xfe, 0x95, + ]; + let spec = SpecConfig::mainnet(); + let bp = get_blob_parameters(419072, &spec.blob_schedule, spec.default_blob_params()); + assert_eq!(bp, BlobParameters { epoch: 419072, max_blobs_per_block: 21 }); + + let digest = compute_fork_digest(spec.fulu_fork_version, &mainnet_gvr, Some(bp)); + assert_eq!(digest, [0x8c, 0x9f, 0x62, 0xfe]); +} From 939513a9d9eea94bbd4d69fbdeafe3776e1a8c25 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Thu, 13 Aug 2026 14:28:10 +0100 Subject: [PATCH 12/13] Add #[timed] to AggregateEntry::add --- crates/beacon_state/tile/src/tile/attestation_pool.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/beacon_state/tile/src/tile/attestation_pool.rs b/crates/beacon_state/tile/src/tile/attestation_pool.rs index aef3207e..c85bfef7 100644 --- a/crates/beacon_state/tile/src/tile/attestation_pool.rs +++ b/crates/beacon_state/tile/src/tile/attestation_pool.rs @@ -142,6 +142,7 @@ impl AggregateEntry { } } + #[timed] fn add(&mut self, position: usize, signature: &Signature) -> InsertOutcome { let (byte, bit) = (position / 8, 1u8 << (position % 8)); if self.participant_bits[byte] & bit != 0 { From b31e7b9d6e4297bbbf2279d1353701ccfd162c01 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Fri, 14 Aug 2026 11:22:20 +0100 Subject: [PATCH 13/13] Update Cargo.lock for flux dependency bump Cargo.toml's flux rev was already updated by the merge from main; Cargo.lock had not caught up yet. --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36fa93a9..2c477c73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1487,7 +1487,7 @@ dependencies = [ [[package]] name = "flux" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "bitcode", "core_affinity", @@ -1508,7 +1508,7 @@ dependencies = [ [[package]] name = "flux-communication" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "directories", "flux-timing", @@ -1524,7 +1524,7 @@ dependencies = [ [[package]] name = "flux-profiler" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "bytesize", "clap", @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "flux-profiler-macros" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "quote", "syn", @@ -1552,7 +1552,7 @@ dependencies = [ [[package]] name = "flux-timing" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "bitcode", "chrono", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "flux-utils" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "bytes", "core_affinity", @@ -4869,7 +4869,7 @@ dependencies = [ [[package]] name = "spine-derive" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "proc-macro2", "quote", @@ -5361,12 +5361,12 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "type-hash" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" [[package]] name = "type-hash-derive" version = "0.1.1" -source = "git+https://github.com/gattaca-com/flux?rev=77ac7ff994c5c80135bfc294729d33cd1d0f5097#77ac7ff994c5c80135bfc294729d33cd1d0f5097" +source = "git+https://github.com/gattaca-com/flux?rev=d6785f1af35336002476c3d97721fcc67fe76dfd#d6785f1af35336002476c3d97721fcc67fe76dfd" dependencies = [ "proc-macro-crate", "proc-macro2",