Skip to content

Commit 699599c

Browse files
authored
fix(state-transition): reject malformed justification state with typed errors (leanSpec #1178) (#502)
## What Ports the reachable robustness guards from leanSpec [#1178](leanEthereum/leanSpec#1178): reject a malformed justification-bookkeeping `State` in `process_attestations` with typed errors instead of silently mis-counting. ## Why `process_attestations` unpacks the flat `justifications_validators` bit list as one validator-sized segment per tracked root. A `State` reconstructed from untrusted bytes (checkpoint sync) satisfies SSZ yet can still break the cross-field invariant `len(justifications_validators) == len(justifications_roots) * validator_count` — SSZ decoding cannot enforce it. ethlambda's index-range unpack (`.get(j)`) does not crash on a mismatch, but it silently produces short/wrong vote segments. These guards close that gap. ## Changes `crates/blockchain/state_transition/src/lib.rs`, in `process_attestations`, before the unpack: - **Empty registry:** reject `validator_count == 0` (`NoValidators`). Belt-and-suspenders — the header stage rejects this first in the normal flow — but the unpack relies on a non-zero segment width directly. - **Vote-list length mismatch:** reject `justifications_validators.len() != justifications_roots.len() * validator_count` (new `JustificationVotesLengthMismatch`). Checks are ordered to match the spec (registry → length → zero-hash). The zero-hash-root guard and the totality of `slot_is_justifiable_after` (the other two items in #1178) were already present in ethlambda, so they are unchanged. For a well-formed state none of these fire, so honest operation and state roots are unaffected. ## Tests - `process_attestations_rejects_justification_votes_length_mismatch` - `process_attestations_rejects_empty_validator_registry` `cargo fmt`, `clippy -D warnings`, and the state-transition lib tests pass.
1 parent f55fe5c commit 699599c

1 file changed

Lines changed: 121 additions & 2 deletions

File tree

  • crates/blockchain/state_transition/src

crates/blockchain/state_transition/src/lib.rs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ pub enum Error {
3737
},
3838
#[error("zero hash found in justifications_roots")]
3939
ZeroHashInJustificationRoots,
40+
#[error(
41+
"justification vote list length {actual} does not equal tracked-root count times validator count {expected}"
42+
)]
43+
JustificationVotesLengthMismatch { expected: usize, actual: usize },
4044
#[error("aggregated attestation has no participants")]
4145
EmptyAggregationBits,
4246
#[error("aggregation bit set at index {index} beyond validator count {validator_count}")]
@@ -238,15 +242,41 @@ fn process_attestations(
238242
attestations: &AggregatedAttestations,
239243
) -> Result<(), Error> {
240244
let _timing = metrics::time_attestations_processing();
241-
// Precondition: justifications_roots must not contain zero hashes (spec state.py L389).
245+
246+
// Validate the justification bookkeeping before unpacking the flat vote list
247+
// (leanSpec #1178). A `State` decoded from untrusted bytes (e.g. checkpoint
248+
// sync) can satisfy SSZ yet still violate these cross-field invariants; without
249+
// these guards the unpack below would silently produce short vote segments.
250+
let validator_count = state.validators.len();
251+
252+
// An empty registry leaves no segment width, so the flat layout cannot be
253+
// recovered. The header stage already rejects this first, but the unpack
254+
// below relies on it directly.
255+
if validator_count == 0 {
256+
return Err(Error::NoValidators);
257+
}
258+
259+
// The flat vote list must hold exactly one full validator segment per tracked
260+
// root; a mismatched length means the segments no longer line up with the roots.
261+
let expected_vote_count = state.justifications_roots.len() * validator_count;
262+
let actual_vote_count = state.justifications_validators.len();
263+
if actual_vote_count != expected_vote_count {
264+
return Err(Error::JustificationVotesLengthMismatch {
265+
expected: expected_vote_count,
266+
actual: actual_vote_count,
267+
});
268+
}
269+
270+
// The zero hash marks a skipped slot, never a real block, so it cannot track
271+
// votes (spec state.py L389).
242272
if state
243273
.justifications_roots
244274
.iter()
245275
.any(|root| root == &H256::ZERO)
246276
{
247277
return Err(Error::ZeroHashInJustificationRoots);
248278
}
249-
let validator_count = state.validators.len();
279+
250280
let mut attestations_processed: u64 = 0;
251281
let mut justifications: HashMap<H256, Vec<bool>> = state
252282
.justifications_roots
@@ -854,4 +884,93 @@ mod tests {
854884
assert_eq!(state.latest_finalized.slot, 4);
855885
assert_eq!(state.latest_finalized.root, r4);
856886
}
887+
888+
/// leanSpec #1178: a `State` whose flat justification vote list is not
889+
/// tracked-root count × validator count is rejected with a typed error rather
890+
/// than silently producing short vote segments. Reachable via a malformed
891+
/// checkpoint-sync anchor, whose SSZ decoding cannot enforce this cross-field
892+
/// invariant.
893+
#[test]
894+
fn process_attestations_rejects_justification_votes_length_mismatch() {
895+
const NUM_VALIDATORS: usize = 4;
896+
let r1 = H256([1u8; 32]);
897+
898+
let mut state = State {
899+
config: ChainConfig { genesis_time: 0 },
900+
slot: 2,
901+
latest_block_header: BlockHeader {
902+
slot: 1,
903+
proposer_index: 0,
904+
parent_root: H256::ZERO,
905+
state_root: H256::ZERO,
906+
body_root: BlockBody::default().hash_tree_root(),
907+
},
908+
latest_justified: Checkpoint {
909+
slot: 0,
910+
root: H256::ZERO,
911+
},
912+
latest_finalized: Checkpoint {
913+
slot: 0,
914+
root: H256::ZERO,
915+
},
916+
historical_block_hashes: SszList::try_from(vec![H256::ZERO, r1]).unwrap(),
917+
justified_slots: JustifiedSlots::new(),
918+
validators: SszList::try_from(make_validators(NUM_VALIDATORS)).unwrap(),
919+
// One tracked root, but a vote list of the wrong width (3, not 1 * 4).
920+
justifications_roots: SszList::try_from(vec![r1]).unwrap(),
921+
justifications_validators: JustificationValidators::with_length(3).unwrap(),
922+
};
923+
924+
let atts: AggregatedAttestations = Vec::<AggregatedAttestation>::new().try_into().unwrap();
925+
let err = process_attestations(&mut state, &atts).unwrap_err();
926+
assert!(
927+
matches!(
928+
err,
929+
Error::JustificationVotesLengthMismatch {
930+
expected: 4,
931+
actual: 3
932+
}
933+
),
934+
"expected JustificationVotesLengthMismatch {{ expected: 4, actual: 3 }}, got {err:?}"
935+
);
936+
}
937+
938+
/// leanSpec #1178: `process_attestations` on a state with no validators is
939+
/// rejected with a typed error. Belt-and-suspenders: the header stage already
940+
/// rejects an empty registry first in the normal flow, but the flat-vote
941+
/// unpack relies on a non-zero segment width directly.
942+
#[test]
943+
fn process_attestations_rejects_empty_validator_registry() {
944+
let mut state = State {
945+
config: ChainConfig { genesis_time: 0 },
946+
slot: 1,
947+
latest_block_header: BlockHeader {
948+
slot: 0,
949+
proposer_index: 0,
950+
parent_root: H256::ZERO,
951+
state_root: H256::ZERO,
952+
body_root: BlockBody::default().hash_tree_root(),
953+
},
954+
latest_justified: Checkpoint {
955+
slot: 0,
956+
root: H256::ZERO,
957+
},
958+
latest_finalized: Checkpoint {
959+
slot: 0,
960+
root: H256::ZERO,
961+
},
962+
historical_block_hashes: SszList::try_from(vec![H256::ZERO]).unwrap(),
963+
justified_slots: JustifiedSlots::new(),
964+
validators: SszList::try_from(make_validators(0)).unwrap(),
965+
justifications_roots: Default::default(),
966+
justifications_validators: JustificationValidators::new(),
967+
};
968+
969+
let atts: AggregatedAttestations = Vec::<AggregatedAttestation>::new().try_into().unwrap();
970+
let err = process_attestations(&mut state, &atts).unwrap_err();
971+
assert!(
972+
matches!(err, Error::NoValidators),
973+
"expected NoValidators, got {err:?}"
974+
);
975+
}
857976
}

0 commit comments

Comments
 (0)