From 29cc787dd1f9c74f853fa6d8b52002422c2790f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:59:44 -0300 Subject: [PATCH] fix(state-transition): bound distinct attestation data in the transition The per-block cap on distinct AttestationData is a transition rule in leanSpec (`process_attestations`), and `fork_choice.on_block` says so explicitly: "The transition itself bounds the distinct-data count. Only the wire-level duplicate prohibition lives here." We enforced it only at the import boundary in `on_block`, so `state_transition()` accepted an over-cap block and then failed on the state root instead. Both block production (`build_block` -> `process_block`) and spec-fixture replay call the transition without going through `on_block`, so neither was bounded. Check it at the top of `process_attestations`, ahead of the justification-bookkeeping guards as the spec does. The `on_block` check stays for now: it runs before signature verification, so an over-cap block is still rejected without paying for proof verification. (leanSpec #536) --- crates/blockchain/state_transition/src/lib.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 403b170a..4b8fa184 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use ethlambda_types::{ ShortRoot, attestation::AttestationData, - block::{AggregatedAttestations, Block, BlockHeader}, + block::{AggregatedAttestations, Block, BlockHeader, MAX_ATTESTATIONS_DATA}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, state::{HISTORICAL_ROOTS_LIMIT, JustificationValidators, State}, @@ -48,6 +48,8 @@ pub enum Error { index: usize, validator_count: usize, }, + #[error("block carries {count} distinct AttestationData entries; maximum is {max}")] + TooManyAttestationData { count: usize, max: usize }, } /// Transition the given pre-state to the block's post-state. @@ -243,6 +245,24 @@ fn process_attestations( ) -> Result<(), Error> { let _timing = metrics::time_attestations_processing(); + // Cap the distinct attestation data a block may carry (leanSpec #536). + // + // Each distinct data allocates a tally sized to the validator set below, so the + // distinct count, not the attestation count, is what drives the work here. Split + // aggregates over one data share their tally and count once. + // + // `on_block` re-checks this before signature verification so a crafted block is + // rejected cheaply, but the bound belongs to the transition: this is the only + // entry point block production and fixture replay share with block import. + let distinct_attestation_data: HashSet<&AttestationData> = + attestations.iter().map(|att| &att.data).collect(); + if distinct_attestation_data.len() > MAX_ATTESTATIONS_DATA { + return Err(Error::TooManyAttestationData { + count: distinct_attestation_data.len(), + max: MAX_ATTESTATIONS_DATA, + }); + } + // Validate the justification bookkeeping before unpacking the flat vote list // (leanSpec #1178). A `State` decoded from untrusted bytes (e.g. checkpoint // sync) can satisfy SSZ yet still violate these cross-field invariants; without