diff --git a/crates/blockchain/state_transition/tests/types.rs b/crates/blockchain/state_transition/tests/types.rs index e8e1d60a..0e766a69 100644 --- a/crates/blockchain/state_transition/tests/types.rs +++ b/crates/blockchain/state_transition/tests/types.rs @@ -23,6 +23,7 @@ impl StateTransitionTestVector { /// A single state transition test case #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StateTransitionTest { #[allow(dead_code)] pub network: String, @@ -32,6 +33,23 @@ pub struct StateTransitionTest { pub pre: TestState, pub blocks: Vec, pub post: Option, + /// Expected post-state `hash_tree_root`, present alongside `post`. Captured + /// only so `deny_unknown_fields` accepts it; the per-field `post` checks + /// already pin the post-state. + #[serde(rename = "postStateRoot")] + #[allow(dead_code)] + pub post_state_root: Option, + /// Expected rejection reason for negative cases. Captured only so + /// `deny_unknown_fields` accepts it; failure is asserted via a missing + /// `post`. + #[serde(rename = "rejectionReason")] + #[allow(dead_code)] + pub rejection_reason: Option, + /// Aggregation proof regime (unused by the STF runner). Captured only so + /// `deny_unknown_fields` accepts it. + #[serde(rename = "proofSetting")] + #[allow(dead_code)] + pub proof_setting: Option, #[serde(rename = "_info")] #[allow(dead_code)] pub info: TestInfo, diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index 3ba670e5..8e60fdef 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeSet, HashMap, HashSet}, path::Path, sync::Arc, }; @@ -9,13 +9,16 @@ use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ attestation::{ AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, + validator_indices, }, block::{Block, SingleMessageAggregate}, primitives::{ByteList, H256, HashTreeRoot as _}, state::{State, anchor_pair_is_consistent}, }; -use ethlambda_test_fixtures::fork_choice::{AttestationCheck, ForkChoiceTestVector, StoreChecks}; +use ethlambda_test_fixtures::fork_choice::{ + AttestationCheck, BlockAttestationCheck, ForkChoiceTestVector, StoreChecks, +}; const SUPPORTED_FIXTURE_FORMAT: &str = "fork_choice_test"; @@ -75,15 +78,36 @@ fn run(path: &Path) -> datatest_stable::Result<()> { .into()); } + // The anchor is always reachable under the label "genesis", matching + // leanSpec's harness which seeds `block_registry = {"genesis": anchor}` + // (true even for checkpoint-sync fixtures anchored past slot 0). Label + // checks such as `sourceRootLabel` and `labelsInStore` rely on it. + let anchor_root = anchor_block.hash_tree_root(); + let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::get_forkchoice_store(backend, anchor_state, anchor_block) .expect("anchor state and block must match"); // Block registry: maps block labels to their roots let mut block_registry: HashMap = HashMap::new(); + block_registry.insert("genesis".to_string(), anchor_root); + + // Cumulative block tree (root -> (slot, parent_root)), never pruned. + // Mirrors leanSpec's `store.blocks`, which only ever grows: the store's + // `LiveChain` index drops entries below the finalized slot, but the + // reference harness keeps every block it ever imported. `reorgDepth` + // and `labelsInStore` must resolve blocks that finalization has already + // pruned from `LiveChain`, so we accumulate live-chain snapshots here + // instead of re-reading the pruned index at check time. + let mut all_blocks: HashMap = store.get_live_chain()?; // Process steps for (step_idx, step) in test.steps.into_iter().enumerate() { + // Head before this step executes, for the `reorgDepth` check. + let old_head = store.head()?; + // Block built/imported this step, for the block-body checks. Mirrors + // leanSpec's per-step `filled_block` (only a block step sets it). + let mut filled_block: Option = None; match step.step_type.as_str() { "block" => { let block_data = step.block.expect("block step missing block data"); @@ -95,6 +119,9 @@ fn run(path: &Path) -> datatest_stable::Result<()> { block_registry.insert(label.clone(), root); } + // The block this step delivers is the leanSpec `filled_block`. + filled_block = Some(block_data.to_block()); + let signed_block = block_data.to_blank_signed_block(); // Advance time to the block's slot unless the test delivers @@ -206,9 +233,22 @@ fn run(path: &Path) -> datatest_stable::Result<()> { } } + // Fold this step's blocks into the cumulative tree before checks so + // ancestry walks see blocks finalization may have just pruned from + // the live-chain index (see `all_blocks` above). + all_blocks.extend(store.get_live_chain()?); + // Validate checks if let Some(checks) = step.checks { - validate_checks(&store, &checks, step_idx, &block_registry)?; + validate_checks( + &store, + &checks, + step_idx, + &block_registry, + old_head, + filled_block.as_ref(), + &all_blocks, + )?; } } } @@ -234,6 +274,9 @@ fn validate_checks( checks: &StoreChecks, step_idx: usize, block_registry: &HashMap, + old_head: H256, + filled_block: Option<&Block>, + all_blocks: &HashMap, ) -> datatest_stable::Result<()> { // Validate time check: fixtures encode the expected store time in intervals // since genesis (matching `Store::time()`). @@ -403,18 +446,297 @@ fn validate_checks( } } + // Validate attestationTargetRootLabel: the attestation target root must + // resolve to the labeled block. + if let Some(ref label) = checks.attestation_target_root_label { + let expected = resolve_label(label, block_registry, step_idx)?; + let actual = store::get_attestation_target(st).root; + if actual != expected { + return Err(format!( + "Step {step_idx}: attestationTargetRootLabel mismatch (label '{label}'): \ + expected {expected:?}, got {actual:?}" + ) + .into()); + } + } + // Validate attestationChecks if let Some(ref att_checks) = checks.attestation_checks { for att_check in att_checks { - validate_attestation_check(st, att_check, step_idx)?; + validate_attestation_check(st, att_check, step_idx, block_registry)?; } } + // Validate the accepted (known) pool's target-slot set: the sorted-unique + // set of target slots keyed in the known aggregated pool must match. + // Mirrors leanSpec's `{data.target.slot for data in pool}` over all + // distinct pool entries. + if let Some(ref expected) = checks.latest_known_aggregated_target_slots { + // Sorted, de-duplicated set of target slots keyed in the known pool. + let actual: Vec = st + .known_aggregated_payloads() + .values() + .map(|(data, _)| data.target.slot) + .collect::>() + .into_iter() + .collect(); + let mut expected_sorted = expected.to_vec(); + expected_sorted.sort_unstable(); + expected_sorted.dedup(); + if actual != expected_sorted.as_slice() { + return Err(format!( + "Step {step_idx}: latestKnownAggregatedTargetSlots mismatch: \ + expected {expected_sorted:?}, got {actual:?}" + ) + .into()); + } + } + + // NOTE: `latestNewAggregatedTargetSlots`, `attestationSignatureTargetSlots` + // and `newPoolProofParticipants` are parsed but NOT asserted here. They + // read the pending (new) aggregated pool and the raw gossip-signature pool, + // which this offline runner does not drive the way leanSpec's harness does: + // it advances ticks without the aggregator role and folds block-borne votes + // straight into the known pool, so the new/signature pools do not track + // leanSpec's contents. The accepted (known) pool, which the runner does + // populate, is asserted above. + // TODO(leanSpec new/signature pools): assert these once the runner drives + // interval-2 aggregation with the aggregator role so the pending and raw + // signature pools mirror leanSpec's. + + // Validate block-body checks against the block built this step. + if let Some(expected_count) = checks.block_attestation_count { + let block = filled_block.ok_or_else(|| { + format!("Step {step_idx}: blockAttestationCount set but no block was built this step") + })?; + let actual = block.body.attestations.len() as u64; + if actual != expected_count { + return Err(format!( + "Step {step_idx}: blockAttestationCount mismatch: expected {expected_count}, \ + got {actual}" + ) + .into()); + } + } + if let Some(ref block_checks) = checks.block_attestations { + let block = filled_block.ok_or_else(|| { + format!("Step {step_idx}: blockAttestations set but no block was built this step") + })?; + validate_block_attestations(block, block_checks, step_idx)?; + } + // Validate lexicographicHeadAmong if let Some(ref fork_labels) = checks.lexicographic_head_among { validate_lexicographic_head_among(st, fork_labels, step_idx, block_registry)?; } + // Validate canonicalEquivocationHeadAmong (leanSpec #1189) + if let Some(ref fork_labels) = checks.canonical_equivocation_head_among { + validate_canonical_equivocation_head_among(st, fork_labels, step_idx, block_registry)?; + } + + // Validate reorgDepth: blocks reachable from the old head but not the new. + // Walk the cumulative block tree, not the live-chain index: a reorg that + // also advanced finalization may have pruned the abandoned branch from + // `LiveChain`, which would undercount the depth. leanSpec walks its + // never-pruned `store.blocks` here for the same reason. + if let Some(expected_depth) = checks.reorg_depth { + let old_ancestors = ancestor_set(all_blocks, old_head); + let new_ancestors = ancestor_set(all_blocks, st.head()?); + let actual_depth = old_ancestors.difference(&new_ancestors).count() as u64; + if actual_depth != expected_depth { + return Err(format!( + "Step {step_idx}: reorgDepth mismatch: expected {expected_depth}, got {actual_depth}" + ) + .into()); + } + } + + // Validate labelsInStore: each labeled block must still be in the block + // tree. Mirrors leanSpec's `root in store.blocks` against the never-pruned + // tree, so a finalized block pruned from the live-chain index still counts. + if let Some(ref labels) = checks.labels_in_store { + for label in labels { + let root = resolve_label(label, block_registry, step_idx)?; + if !all_blocks.contains_key(&root) { + return Err(format!( + "Step {step_idx}: labelsInStore: block '{label}' (root={root:?}) \ + not found in the store" + ) + .into()); + } + } + } + + Ok(()) +} + +/// Resolve a block label to its root via the step's block registry. +fn resolve_label( + label: &str, + block_registry: &HashMap, + step_idx: usize, +) -> datatest_stable::Result { + block_registry.get(label).copied().ok_or_else(|| { + format!( + "Step {step_idx}: label '{label}' not found in block registry. Available: {:?}", + block_registry.keys().collect::>() + ) + .into() + }) +} + +/// Walk parent links from `head`, collecting every reachable block root. +/// +/// Mirrors leanSpec's `_ancestor_set`: only roots present in the block tree are +/// collected, so the walk stops at the anchor (whose parent is not in the tree). +fn ancestor_set(blocks: &HashMap, head: H256) -> HashSet { + let mut seen = HashSet::new(); + let mut root = head; + while let Some(&(_, parent_root)) = blocks.get(&root) { + seen.insert(root); + if parent_root == H256::ZERO { + break; + } + root = parent_root; + } + seen +} + +/// Validate the detailed per-aggregate checks against a built block body. +/// +/// Mirrors leanSpec's `_validate_block_attestations`: each expected check must +/// match an aggregate whose participant set is exactly equal, then the matched +/// aggregate's slot/target-slot are compared. +fn validate_block_attestations( + block: &Block, + expected_checks: &[BlockAttestationCheck], + step_idx: usize, +) -> datatest_stable::Result<()> { + let actual: Vec<(BTreeSet, &AttestationData)> = block + .body + .attestations + .iter() + .map(|att| { + ( + validator_indices(&att.aggregation_bits).collect(), + &att.data, + ) + }) + .collect(); + + for check in expected_checks { + let expected_participants: BTreeSet = check.participants.iter().copied().collect(); + let matched = actual + .iter() + .find(|(participants, _)| *participants == expected_participants); + let (_, data) = matched.ok_or_else(|| { + let available: Vec<_> = actual + .iter() + .map(|(p, _)| p.iter().collect::>()) + .collect(); + format!( + "Step {step_idx}: blockAttestations: no aggregate with participants \ + {expected_participants:?}. Available: {available:?}" + ) + })?; + + if let Some(expected_slot) = check.attestation_slot + && data.slot != expected_slot + { + return Err(format!( + "Step {step_idx}: blockAttestations: aggregate {expected_participants:?} \ + attestationSlot mismatch: expected {expected_slot}, got {}", + data.slot + ) + .into()); + } + if let Some(expected_target) = check.target_slot + && data.target.slot != expected_target + { + return Err(format!( + "Step {step_idx}: blockAttestations: aggregate {expected_participants:?} \ + targetSlot mismatch: expected {expected_target}, got {}", + data.target.slot + ) + .into()); + } + } + Ok(()) +} + +/// Validate the equal-slot equivocation tiebreak (leanSpec #1189). +/// +/// Each listed fork must be targeted by an attestation in the accepted (known) +/// aggregated pool; the head must sit on the fork whose targeting attestation +/// carries the largest `hash_tree_root` (the pool key). Scheme-independent: +/// roots are read from the store, never pinned by the fixture. +fn validate_canonical_equivocation_head_among( + st: &Store, + fork_labels: &[String], + step_idx: usize, + block_registry: &HashMap, +) -> datatest_stable::Result<()> { + if fork_labels.len() < 2 { + return Err(format!( + "Step {step_idx}: canonicalEquivocationHeadAmong requires at least 2 forks, got {}", + fork_labels.len() + ) + .into()); + } + + // The known aggregated pool is keyed by hash_tree_root(AttestationData). + let known = st.known_aggregated_payloads(); + + // Per fork: block root, and the largest attestation-data root targeting it. + let mut fork_data: Vec<(&str, H256, H256)> = Vec::with_capacity(fork_labels.len()); + for label in fork_labels { + let fork_root = resolve_label(label, block_registry, step_idx)?; + let max_att_root = known + .iter() + .filter(|(_, (data, _))| data.target.root == fork_root) + .map(|(data_root, _)| *data_root) + .max() + .ok_or_else(|| { + format!( + "Step {step_idx}: canonicalEquivocationHeadAmong fork '{label}' \ + (block_root={fork_root:?}) has no attestation targeting it in the \ + accepted aggregated pool" + ) + })?; + fork_data.push((label.as_str(), fork_root, max_att_root)); + } + + // Winner: the fork carrying the largest attestation-data root. + let (winning_label, winning_fork_root, _) = fork_data + .iter() + .max_by_key(|(_, _, att_root)| *att_root) + .expect("fork_data is non-empty"); + + let actual_head = st.head()?; + if actual_head != *winning_fork_root { + let actual_label = fork_data + .iter() + .find(|(_, root, _)| *root == actual_head) + .map(|(label, _, _)| *label) + .unwrap_or("unknown"); + let fork_info: Vec = fork_data + .iter() + .map(|(label, root, att_root)| { + format!(" {label}: block_root={root:?} attestation_data_root={att_root:?}") + }) + .collect(); + return Err(format!( + "Step {step_idx}: canonical equivocation tiebreak failed.\n\ + The head must be the fork with the largest attestation-data root.\n\ + Expected head: '{winning_label}' ({winning_fork_root:?})\n\ + Actual head: '{actual_label}' ({actual_head:?})\n\ + Competing forks:\n{}", + fork_info.join("\n") + ) + .into()); + } + Ok(()) } @@ -422,6 +744,7 @@ fn validate_attestation_check( st: &Store, check: &AttestationCheck, step_idx: usize, + block_registry: &HashMap, ) -> datatest_stable::Result<()> { let validator_id = check.validator; let location = check.location.as_str(); @@ -444,6 +767,19 @@ fn validate_attestation_check( ) })?; + // Validate source root by label if specified. + if let Some(ref label) = check.source_root_label { + let expected = resolve_label(label, block_registry, step_idx)?; + if attestation.source.root != expected { + return Err(format!( + "Step {step_idx}: attestation source root mismatch for validator {validator_id} \ + (label '{label}'): expected {expected:?}, got {:?}", + attestation.source.root + ) + .into()); + } + } + // Validate attestation slot if specified if let Some(expected_slot) = check.attestation_slot && attestation.slot != expected_slot diff --git a/crates/common/test-fixtures/src/common.rs b/crates/common/test-fixtures/src/common.rs index 54c4c31b..6293b897 100644 --- a/crates/common/test-fixtures/src/common.rs +++ b/crates/common/test-fixtures/src/common.rs @@ -21,6 +21,7 @@ use serde::Deserialize; // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Container { pub data: Vec, } @@ -30,6 +31,7 @@ pub struct Container { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Config { #[serde(rename = "genesisTime")] pub genesis_time: u64, @@ -48,6 +50,7 @@ impl From for ChainConfig { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Checkpoint { pub root: H256, pub slot: u64, @@ -67,6 +70,7 @@ impl From for DomainCheckpoint { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct BlockHeader { pub slot: u64, #[serde(rename = "proposerIndex")] @@ -96,6 +100,7 @@ impl From for ethlambda_types::block::BlockHeader { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Validator { index: u64, #[serde(rename = "attestationPublicKey")] @@ -121,6 +126,7 @@ impl From for DomainValidator { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TestState { pub config: Config, pub slot: u64, @@ -186,6 +192,7 @@ impl From for State { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Block { pub slot: u64, #[serde(rename = "proposerIndex")] @@ -210,6 +217,7 @@ impl From for DomainBlock { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct BlockBody { pub attestations: Container, } @@ -233,6 +241,7 @@ impl From for DomainBlockBody { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AggregatedAttestation { #[serde(rename = "aggregationBits")] pub aggregation_bits: AggregationBits, @@ -249,6 +258,7 @@ impl From for DomainAggregatedAttestation { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AggregationBits { pub data: Vec, } @@ -264,6 +274,7 @@ impl From for DomainAggregationBits { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AttestationData { pub slot: u64, pub head: Checkpoint, @@ -287,6 +298,7 @@ impl From for DomainAttestationData { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TestInfo { pub hash: String, pub comment: String, @@ -295,6 +307,12 @@ pub struct TestInfo { pub description: String, #[serde(rename = "fixtureFormat")] pub fixture_format: String, + /// Digest of the validator key set the fixture was generated against. + /// Present on every current fixture; captured only so `deny_unknown_fields` + /// does not reject it. + #[serde(rename = "keySetDigest")] + #[allow(dead_code)] + pub key_set_digest: String, } // ============================================================================ diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index f7a07466..a6d2952f 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -33,6 +33,7 @@ impl ForkChoiceTestVector { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ForkChoiceTest { #[allow(dead_code)] pub network: String, @@ -52,6 +53,11 @@ pub struct ForkChoiceTest { #[serde(rename = "maxSlot")] #[allow(dead_code)] pub max_slot: u64, + /// Top-level expected rejection reason for whole-vector negative tests. + /// Captured only so `deny_unknown_fields` accepts it. + #[serde(rename = "rejectionReason")] + #[allow(dead_code)] + pub rejection_reason: Option, #[serde(rename = "_info")] pub info: TestInfo, } @@ -73,6 +79,7 @@ impl ForkChoiceTest { // ============================================================================ #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ForkChoiceStep { /// Whether this step is expected to be accepted by the store. /// @@ -98,6 +105,20 @@ pub struct ForkChoiceStep { /// `false` to deliver the block ahead of the store clock. #[serde(rename = "tickToSlot", default = "default_true")] pub tick_to_slot: bool, + /// Full canonical store snapshot the simulator emits after every step + /// (leanSpec `StoreSnapshot`). Captured but not yet asserted by the offline + /// runner. + // TODO(leanSpec storeSnapshot): assert the snapshot contents against Store + // getters (block roots, block weights, aggregated-payload participant sets, + // gossip-signature groups) once the required Store plumbing exists. + #[serde(rename = "storeSnapshot")] + pub store_snapshot: Option, + /// Expected rejection reason for a step marked `valid: false`. Captured only + /// so `deny_unknown_fields` accepts it; step outcomes are asserted via the + /// `valid` flag. + #[serde(rename = "rejectionReason")] + #[allow(dead_code)] + pub rejection_reason: Option, } fn default_true() -> bool { @@ -105,6 +126,7 @@ fn default_true() -> bool { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AttestationStepData { #[serde(rename = "validatorIndex")] pub validator_id: Option, @@ -122,6 +144,7 @@ pub struct AttestationStepData { /// `{ data: "0x" }`; the latter is the lean-multisig single-message /// aggregate `compress_without_pubkeys()` bytes for that AttestationData. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProofStepData { pub participants: AggregationBits, pub proof: HexByteList, @@ -129,6 +152,7 @@ pub struct ProofStepData { /// Hex-encoded byte list in the fixture format: `{ "data": "0xdeadbeef" }`. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct HexByteList { data: String, } @@ -151,6 +175,7 @@ where } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct BlockStepData { pub slot: u64, #[serde(rename = "proposerIndex")] @@ -199,6 +224,7 @@ impl BlockStepData { /// Root-typed fields have a `*RootLabel` companion that resolves a block label via the /// step's block registry, mirroring the leanSpec fixture schema. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StoreChecks { /// Expected store time in intervals since genesis. pub time: Option, @@ -244,13 +270,72 @@ pub struct StoreChecks { #[serde(rename = "attestationTargetSlot")] pub attestation_target_slot: Option, + /// Expected attestation target block root by label reference. + #[serde(rename = "attestationTargetRootLabel")] + pub attestation_target_root_label: Option, #[serde(rename = "attestationChecks")] pub attestation_checks: Option>, #[serde(rename = "lexicographicHeadAmong")] pub lexicographic_head_among: Option>, + + /// Equal-slot equivocation tiebreak: the listed fork labels must each be + /// targeted by an attestation in the accepted (known) aggregated pool, and + /// the head must sit on the fork whose attestation carries the largest + /// `hash_tree_root` (leanSpec #1189). Scheme-independent: roots are read + /// from the store, never pinned. + #[serde(rename = "canonicalEquivocationHeadAmong")] + pub canonical_equivocation_head_among: Option>, + + /// Expected sorted-unique set of target slots keyed in the raw gossip + /// signature pool. + #[serde(rename = "attestationSignatureTargetSlots")] + pub attestation_signature_target_slots: Option>, + /// Expected sorted-unique set of target slots keyed in the pending (new) + /// aggregated proof pool. + #[serde(rename = "latestNewAggregatedTargetSlots")] + pub latest_new_aggregated_target_slots: Option>, + /// Expected sorted-unique set of target slots keyed in the accepted (known) + /// aggregated proof pool. + #[serde(rename = "latestKnownAggregatedTargetSlots")] + pub latest_known_aggregated_target_slots: Option>, + + /// Expected union of validator indices across pending-pool proofs, keyed by + /// target slot. JSON object keys are stringified slots (e.g. `"1"`); parse + /// them to `u64` at assertion time. + #[serde(rename = "newPoolProofParticipants")] + pub new_pool_proof_participants: Option>>, + + /// Expected number of aggregated attestations in the block built for this + /// step. More than one means votes split over incompatible sources. + #[serde(rename = "blockAttestationCount")] + pub block_attestation_count: Option, + /// Detailed per-aggregate checks on the block built for this step. + #[serde(rename = "blockAttestations")] + pub block_attestations: Option>, + + /// Expected count of blocks from the previous head back to its common + /// ancestor with the new head. + #[serde(rename = "reorgDepth")] + pub reorg_depth: Option, + + /// Block labels that must still be present in the block tree (verifying + /// abandoned forks are retained). + #[serde(rename = "labelsInStore")] + pub labels_in_store: Option>, + + /// Expected root of the block built for this step, named by label. The + /// offline runner does not build blocks (it imports fixture-supplied ones), + /// so this is captured but not asserted. + // TODO(leanSpec filledBlockRootLabel): assert once a block-building step + // exists; the offline runner imports blocks rather than building them. + #[serde(rename = "filledBlockRootLabel")] + #[allow(dead_code)] + pub filled_block_root_label: Option, } +/// Per-validator attestation content check within a step's `attestationChecks`. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AttestationCheck { pub validator: u64, #[serde(rename = "attestationSlot")] @@ -259,7 +344,88 @@ pub struct AttestationCheck { pub head_slot: Option, #[serde(rename = "sourceSlot")] pub source_slot: Option, + /// Expected source checkpoint root, named by label and resolved to a root. + #[serde(rename = "sourceRootLabel")] + pub source_root_label: Option, #[serde(rename = "targetSlot")] pub target_slot: Option, pub location: String, } + +/// Checks for one aggregated attestation in a built block body +/// (leanSpec `AggregatedAttestationCheck`). +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BlockAttestationCheck { + /// Validator indices this aggregate must cover exactly. + pub participants: Vec, + #[serde(rename = "attestationSlot")] + pub attestation_slot: Option, + #[serde(rename = "targetSlot")] + pub target_slot: Option, +} + +// ============================================================================ +// Store snapshot (leanSpec `StoreSnapshot`) +// ============================================================================ + +/// Canonical store snapshot emitted after every fork-choice step. +/// +/// Parsed in full so `deny_unknown_fields` accepts it, but not yet asserted by +/// the offline runner; see the `TODO(leanSpec storeSnapshot)` on +/// [`ForkChoiceStep::store_snapshot`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +pub struct StoreSnapshot { + pub time: u64, + #[serde(rename = "headRoot")] + pub head_root: H256, + #[serde(rename = "safeTargetRoot")] + pub safe_target_root: H256, + #[serde(rename = "latestJustified")] + pub latest_justified: Checkpoint, + #[serde(rename = "latestFinalized")] + pub latest_finalized: Checkpoint, + #[serde(rename = "blockRoots")] + pub block_roots: Vec, + #[serde(rename = "blockWeights")] + pub block_weights: Vec, + #[serde(rename = "knownAggregatedPayloads")] + pub known_aggregated_payloads: Vec, + #[serde(rename = "newAggregatedPayloads")] + pub new_aggregated_payloads: Vec, + #[serde(rename = "attestationSignatures")] + pub attestation_signatures: Vec, +} + +/// A `{root, weight}` entry in [`StoreSnapshot::block_weights`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +pub struct BlockWeightEntry { + pub root: H256, + pub weight: u64, +} + +/// A `{dataRoot, participantSets}` entry in the snapshot's aggregated pools. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +pub struct AggregatedPayloadEntry { + #[serde(rename = "dataRoot")] + pub data_root: H256, + #[serde(rename = "participantSets")] + pub participant_sets: Vec>, +} + +/// A `{dataRoot, validatorIndices}` entry in the snapshot's signature pool. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +pub struct AttestationSignatureEntry { + #[serde(rename = "dataRoot")] + pub data_root: H256, + #[serde(rename = "validatorIndices")] + pub validator_indices: Vec, +} diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index 4c25b3b7..038ee5d7 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -34,6 +34,7 @@ impl VerifySignaturesTestVector { /// A single verify-signatures test case. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct VerifySignaturesTest { #[allow(dead_code)] pub network: String, @@ -49,6 +50,11 @@ pub struct VerifySignaturesTest { /// spellings are accepted. #[serde(default, rename = "expectException", alias = "rejectionReason")] pub expect_exception: Option, + /// Aggregation proof regime (see [`crate::fork_choice::ForkChoiceTest`]). + /// Captured only so `deny_unknown_fields` accepts it. + #[serde(rename = "proofSetting")] + #[allow(dead_code)] + pub proof_setting: Option, #[serde(rename = "_info")] #[allow(dead_code)] pub info: TestInfo, @@ -57,6 +63,7 @@ pub struct VerifySignaturesTest { /// Fixture-side signed block: a block plus its raw merged multi-message /// aggregate proof bytes. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TestSignedBlock { #[serde(alias = "message")] pub block: Block, @@ -69,6 +76,7 @@ pub struct TestSignedBlock { /// The multi-signature container nests the raw lean-multisig wire one level /// deep: `{ "proof": { "data": "0x..." } }`. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct MergedProof { pub proof: HexBytes, } @@ -81,6 +89,7 @@ impl MergedProof { /// `{ "data": "0x..." }` wrapper used by leanSpec fixtures for byte fields. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct HexBytes { pub data: String, }