|
1 | | -use std::collections::HashMap; |
| 1 | +use std::collections::{HashMap, HashSet}; |
2 | 2 |
|
3 | 3 | use ethlambda_types::{ |
4 | 4 | ShortRoot, |
5 | 5 | attestation::AttestationData, |
6 | | - block::{AggregatedAttestations, Block, BlockHeader}, |
| 6 | + block::{AggregatedAttestations, Block, BlockHeader, MAX_ATTESTATIONS_DATA}, |
7 | 7 | checkpoint::Checkpoint, |
8 | 8 | primitives::{H256, HashTreeRoot as _}, |
9 | 9 | state::{HISTORICAL_ROOTS_LIMIT, JustificationValidators, State}, |
@@ -48,6 +48,8 @@ pub enum Error { |
48 | 48 | index: usize, |
49 | 49 | validator_count: usize, |
50 | 50 | }, |
| 51 | + #[error("block carries {count} distinct AttestationData entries; maximum is {max}")] |
| 52 | + TooManyAttestationData { count: usize, max: usize }, |
51 | 53 | } |
52 | 54 |
|
53 | 55 | /// Transition the given pre-state to the block's post-state. |
@@ -243,6 +245,24 @@ fn process_attestations( |
243 | 245 | ) -> Result<(), Error> { |
244 | 246 | let _timing = metrics::time_attestations_processing(); |
245 | 247 |
|
| 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 | + |
246 | 266 | // Validate the justification bookkeeping before unpacking the flat vote list |
247 | 267 | // (leanSpec #1178). A `State` decoded from untrusted bytes (e.g. checkpoint |
248 | 268 | // sync) can satisfy SSZ yet still violate these cross-field invariants; without |
@@ -973,4 +993,99 @@ mod tests { |
973 | 993 | "expected NoValidators, got {err:?}" |
974 | 994 | ); |
975 | 995 | } |
| 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 | + } |
976 | 1091 | } |
0 commit comments