Skip to content

Commit 3a8ead6

Browse files
committed
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)
1 parent fc1f83a commit 3a8ead6

1 file changed

Lines changed: 117 additions & 2 deletions

File tree

  • crates/blockchain/state_transition/src

crates/blockchain/state_transition/src/lib.rs

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
use std::collections::HashMap;
1+
use std::collections::{HashMap, HashSet};
22

33
use ethlambda_types::{
44
ShortRoot,
55
attestation::AttestationData,
6-
block::{AggregatedAttestations, Block, BlockHeader},
6+
block::{AggregatedAttestations, Block, BlockHeader, MAX_ATTESTATIONS_DATA},
77
checkpoint::Checkpoint,
88
primitives::{H256, HashTreeRoot as _},
99
state::{HISTORICAL_ROOTS_LIMIT, JustificationValidators, State},
@@ -48,6 +48,8 @@ pub enum Error {
4848
index: usize,
4949
validator_count: usize,
5050
},
51+
#[error("block carries {count} distinct AttestationData entries; maximum is {max}")]
52+
TooManyAttestationData { count: usize, max: usize },
5153
}
5254

5355
/// Transition the given pre-state to the block's post-state.
@@ -243,6 +245,24 @@ fn process_attestations(
243245
) -> Result<(), Error> {
244246
let _timing = metrics::time_attestations_processing();
245247

248+
// Cap the distinct attestation data a block may carry (leanSpec #536).
249+
//
250+
// Each distinct data allocates a tally sized to the validator set below, so the
251+
// distinct count, not the attestation count, is what drives the work here. Split
252+
// aggregates over one data share their tally and count once.
253+
//
254+
// `on_block` re-checks this before signature verification so a crafted block is
255+
// rejected cheaply, but the bound belongs to the transition: this is the only
256+
// entry point block production and fixture replay share with block import.
257+
let distinct_attestation_data: HashSet<&AttestationData> =
258+
attestations.iter().map(|att| &att.data).collect();
259+
if distinct_attestation_data.len() > MAX_ATTESTATIONS_DATA {
260+
return Err(Error::TooManyAttestationData {
261+
count: distinct_attestation_data.len(),
262+
max: MAX_ATTESTATIONS_DATA,
263+
});
264+
}
265+
246266
// Validate the justification bookkeeping before unpacking the flat vote list
247267
// (leanSpec #1178). A `State` decoded from untrusted bytes (e.g. checkpoint
248268
// sync) can satisfy SSZ yet still violate these cross-field invariants; without
@@ -973,4 +993,99 @@ mod tests {
973993
"expected NoValidators, got {err:?}"
974994
);
975995
}
996+
997+
/// Build `count` attestations carrying pairwise-distinct `AttestationData`.
998+
fn distinct_attestations(count: usize, validator_count: usize) -> AggregatedAttestations {
999+
let r1 = H256([1u8; 32]);
1000+
let attestations: Vec<AggregatedAttestation> = (0..count)
1001+
.map(|i| {
1002+
make_attestation(
1003+
i as u64 + 1,
1004+
(0, H256::ZERO),
1005+
(1, r1),
1006+
(1, r1),
1007+
&[0],
1008+
validator_count,
1009+
)
1010+
})
1011+
.collect();
1012+
attestations
1013+
.try_into()
1014+
.expect("count is under the SSZ limit")
1015+
}
1016+
1017+
/// A state whose only interesting property is its validator count, so the
1018+
/// cap can be exercised without satisfying the vote-tracking invariants.
1019+
fn state_for_cap_tests(validator_count: usize) -> State {
1020+
let r1 = H256([1u8; 32]);
1021+
State {
1022+
config: ChainConfig { genesis_time: 0 },
1023+
slot: 2,
1024+
latest_block_header: BlockHeader {
1025+
slot: 1,
1026+
proposer_index: 0,
1027+
parent_root: H256::ZERO,
1028+
state_root: H256::ZERO,
1029+
body_root: BlockBody::default().hash_tree_root(),
1030+
},
1031+
latest_justified: Checkpoint {
1032+
slot: 0,
1033+
root: H256::ZERO,
1034+
},
1035+
latest_finalized: Checkpoint {
1036+
slot: 0,
1037+
root: H256::ZERO,
1038+
},
1039+
historical_block_hashes: SszList::try_from(vec![H256::ZERO, r1]).unwrap(),
1040+
justified_slots: JustifiedSlots::new(),
1041+
validators: SszList::try_from(make_validators(validator_count)).unwrap(),
1042+
justifications_roots: Default::default(),
1043+
justifications_validators: JustificationValidators::new(),
1044+
}
1045+
}
1046+
1047+
/// leanSpec #536: the distinct-attestation-data cap belongs to the
1048+
/// transition, not only to the import boundary in `on_block`. Each distinct
1049+
/// data allocates a per-validator tally, so a block that slips past the
1050+
/// proposer-side clamp must be rejected here too.
1051+
#[test]
1052+
fn process_attestations_rejects_more_distinct_data_than_the_cap() {
1053+
const NUM_VALIDATORS: usize = 4;
1054+
1055+
let atts = distinct_attestations(MAX_ATTESTATIONS_DATA + 1, NUM_VALIDATORS);
1056+
let err =
1057+
process_attestations(&mut state_for_cap_tests(NUM_VALIDATORS), &atts).unwrap_err();
1058+
assert!(
1059+
matches!(
1060+
err,
1061+
Error::TooManyAttestationData { count, max }
1062+
if count == MAX_ATTESTATIONS_DATA + 1 && max == MAX_ATTESTATIONS_DATA
1063+
),
1064+
"expected TooManyAttestationData over the cap, got {err:?}"
1065+
);
1066+
1067+
// The bound is inclusive: a block exactly at the cap clears this check.
1068+
let atts = distinct_attestations(MAX_ATTESTATIONS_DATA, NUM_VALIDATORS);
1069+
let result = process_attestations(&mut state_for_cap_tests(NUM_VALIDATORS), &atts);
1070+
assert!(
1071+
!matches!(result, Err(Error::TooManyAttestationData { .. })),
1072+
"a block at the cap must not be rejected for exceeding it, got {result:?}"
1073+
);
1074+
}
1075+
1076+
/// The cap is checked before the justification-bookkeeping guards, matching
1077+
/// the spec's order inside `process_attestations`: an over-cap block reports
1078+
/// the cap even when the state would also fail a later guard.
1079+
#[test]
1080+
fn attestation_data_cap_precedes_the_justification_guards() {
1081+
// An empty registry is rejected by the guard immediately after the cap.
1082+
let mut state = state_for_cap_tests(0);
1083+
let atts = distinct_attestations(MAX_ATTESTATIONS_DATA + 1, 4);
1084+
1085+
let err = process_attestations(&mut state, &atts).unwrap_err();
1086+
assert!(
1087+
matches!(err, Error::TooManyAttestationData { .. }),
1088+
"expected the cap to be reported ahead of NoValidators, got {err:?}"
1089+
);
1090+
}
9761091
}

0 commit comments

Comments
 (0)