Skip to content

Commit 3702d6d

Browse files
committed
refactor(test): unify Hive and spec-test fork-choice runner
1 parent c61165a commit 3702d6d

5 files changed

Lines changed: 132 additions & 253 deletions

File tree

crates/blockchain/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ ethlambda-fork-choice.workspace = true
1818
ethlambda-crypto.workspace = true
1919
ethlambda-metrics.workspace = true
2020
ethlambda-types.workspace = true
21+
ethlambda-test-fixtures.workspace = true
2122

2223
libssz.workspace = true
2324

@@ -33,7 +34,6 @@ tracing.workspace = true
3334
hex.workspace = true
3435

3536
[dev-dependencies]
36-
ethlambda-test-fixtures.workspace = true
3737
serde = { workspace = true }
3838
serde_json = { workspace = true }
3939
hex = { workspace = true }

crates/blockchain/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub(crate) mod fork_choice_tree;
3737
pub mod key_manager;
3838
pub mod metrics;
3939
pub mod reaggregate;
40+
pub mod spec_test_runner;
4041
pub mod store;
4142
mod sync_status;
4243

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//! Shared execution primitives for leanSpec fixtures.
2+
//!
3+
//! Both the offline spec-test binaries and Hive's HTTP test driver use these
4+
//! functions so fixture replay cannot drift between the two entry points.
5+
6+
use ethlambda_storage::Store;
7+
use ethlambda_test_fixtures::fork_choice::ForkChoiceStep;
8+
use ethlambda_types::{
9+
attestation::{
10+
AggregationBits, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation,
11+
},
12+
block::{ByteList512KiB, SingleMessageAggregate},
13+
};
14+
15+
use crate::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, store};
16+
17+
/// Prefix emitted by leanSpec's mocked aggregation prover.
18+
const MOCK_PROOF_PREFIX: &[u8] = b"\x00MOCKED-AGGREGATION-PROOF\x00";
19+
20+
/// Apply one fork-choice fixture step.
21+
///
22+
/// `proofs_are_mocked` is supplied by complete offline vectors through their
23+
/// `proofSetting`. Hive sends individual steps, so `None` detects the mocked
24+
/// prover's sentinel directly from the proof bytes.
25+
pub fn apply_fork_choice_step(
26+
store: &mut Store,
27+
step: &ForkChoiceStep,
28+
proofs_are_mocked: Option<bool>,
29+
) -> Result<(), String> {
30+
match step.step_type.as_str() {
31+
"tick" => {
32+
let genesis_time = store.config().expect("config exists").genesis_time;
33+
let timestamp_ms = match (step.time, step.interval) {
34+
(Some(time_s), _) => time_s * 1000,
35+
(None, Some(interval)) => {
36+
genesis_time * 1000 + interval * MILLISECONDS_PER_INTERVAL
37+
}
38+
(None, None) => return Err("tick step missing time and interval".to_string()),
39+
};
40+
store::on_tick(store, timestamp_ms, step.has_proposal.unwrap_or(false));
41+
Ok(())
42+
}
43+
"block" => {
44+
let block_data = step
45+
.block
46+
.as_ref()
47+
.ok_or_else(|| "block step missing block data".to_string())?;
48+
let signed_block = block_data.to_blank_signed_block();
49+
if step.tick_to_slot {
50+
let block_time_ms = store.config().expect("config exists").genesis_time * 1000
51+
+ signed_block.message.slot * MILLISECONDS_PER_SLOT;
52+
store::on_tick(store, block_time_ms, true);
53+
}
54+
store::on_block_without_verification(store, signed_block).map_err(|e| e.to_string())?;
55+
56+
let block = block_data.to_block();
57+
let entries = block.body.attestations.iter().map(|att| {
58+
(
59+
HashedAttestationData::new(att.data.clone()),
60+
SingleMessageAggregate::empty(att.aggregation_bits.clone()),
61+
)
62+
});
63+
store.insert_known_aggregated_payloads_batch(entries.collect());
64+
store::update_head(store, false);
65+
Ok(())
66+
}
67+
"attestation" => {
68+
let att = step
69+
.attestation
70+
.as_ref()
71+
.ok_or_else(|| "attestation step missing data".to_string())?;
72+
let signed = SignedAttestation {
73+
validator_id: att
74+
.validator_id
75+
.ok_or_else(|| "attestation step missing validatorId".to_string())?,
76+
data: att.data.clone().into(),
77+
signature: att
78+
.signature
79+
.clone()
80+
.ok_or_else(|| "attestation step missing signature".to_string())?,
81+
};
82+
store::on_gossip_attestation(store, &signed, step.is_aggregator.unwrap_or(false))
83+
.map_err(|e| e.to_string())
84+
}
85+
"gossipAggregatedAttestation" => {
86+
let att = step
87+
.attestation
88+
.as_ref()
89+
.ok_or_else(|| "gossipAggregatedAttestation step missing data".to_string())?;
90+
let proof = att
91+
.proof
92+
.as_ref()
93+
.ok_or_else(|| "gossipAggregatedAttestation step missing proof".to_string())?;
94+
let participants: AggregationBits = proof.participants.clone().into();
95+
let proof_bytes: Vec<u8> = proof.proof.clone().into();
96+
let is_mocked =
97+
proofs_are_mocked.unwrap_or_else(|| proof_bytes.starts_with(MOCK_PROOF_PREFIX));
98+
let proof_data = ByteList512KiB::try_from(proof_bytes)
99+
.map_err(|err| format!("aggregated proof data too large: {err:?}"))?;
100+
let aggregated = SignedAggregatedAttestation {
101+
proof: SingleMessageAggregate::new(participants, proof_data),
102+
data: att.data.clone().into(),
103+
};
104+
if is_mocked {
105+
store::on_gossip_aggregated_attestation_without_verification(store, aggregated)
106+
.map_err(|e| e.to_string())
107+
} else {
108+
store::on_gossip_aggregated_attestation(store, aggregated)
109+
.map_err(|e| e.to_string())
110+
}
111+
}
112+
"checks" => Ok(()),
113+
other => Err(format!("unknown step type: {other}")),
114+
}
115+
}

crates/blockchain/tests/forkchoice_spectests.rs

Lines changed: 12 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,12 @@ use std::{
44
sync::Arc,
55
};
66

7-
use ethlambda_blockchain::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, store};
7+
use ethlambda_blockchain::{spec_test_runner::apply_fork_choice_step, store};
88
use ethlambda_storage::{Store, backend::InMemoryBackend};
99
use ethlambda_types::{
10-
attestation::{
11-
AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation,
12-
validator_indices,
13-
},
14-
block::{Block, SingleMessageAggregate},
15-
primitives::{ByteList, H256, HashTreeRoot as _},
10+
attestation::{AttestationData, validator_indices},
11+
block::Block,
12+
primitives::{H256, HashTreeRoot as _},
1613
state::{State, anchor_pair_is_consistent},
1714
};
1815

@@ -57,8 +54,6 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
5754
// `get_forkchoice_store`'s assert! panic out of the test harness.
5855
let mut anchor_state: State = test.anchor_state.into();
5956
let anchor_block: Block = test.anchor_block.into();
60-
let genesis_time = anchor_state.config.genesis_time;
61-
6257
let pair_ok = anchor_pair_is_consistent(&mut anchor_state, &anchor_block);
6358
if test.steps.is_empty() {
6459
if pair_ok {
@@ -107,132 +102,16 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
107102
let old_head = store.head()?;
108103
// Block built/imported this step, for the block-body checks. Mirrors
109104
// leanSpec's per-step `filled_block` (only a block step sets it).
110-
let mut filled_block: Option<Block> = None;
111-
match step.step_type.as_str() {
112-
"block" => {
113-
let block_data = step.block.expect("block step missing block data");
114-
115-
// Register block label if present
116-
if let Some(ref label) = block_data.block_root_label {
117-
let block: Block = block_data.to_block();
118-
let root = block.hash_tree_root();
119-
block_registry.insert(label.clone(), root);
120-
}
121-
122-
// The block this step delivers is the leanSpec `filled_block`.
123-
filled_block = Some(block_data.to_block());
124-
125-
let signed_block = block_data.to_blank_signed_block();
126-
127-
// Advance time to the block's slot unless the test delivers
128-
// the block ahead of the store clock.
129-
// NOTE: the has_proposal argument is set to true, following the spec
130-
if step.tick_to_slot {
131-
let block_time_ms =
132-
genesis_time * 1000 + signed_block.message.slot * MILLISECONDS_PER_SLOT;
133-
store::on_tick(&mut store, block_time_ms, true);
134-
}
135-
let result = store::on_block_without_verification(&mut store, signed_block);
136-
let import_ok = result.is_ok();
137-
assert_step_outcome(step_idx, step.valid, result)?;
138-
139-
// Deconstruct the imported block into per-attestation
140-
// single-message aggregates, mirroring the node's post-import
141-
// reaggregation. The real node SNARK-splits the block's merged
142-
// multi-message aggregate proof and folds the recovered
143-
// single-message aggregates into the pool so block-borne votes carry
144-
// fork-choice weight; leanSpec's fork-choice harness gets the
145-
// same effect by simulating the proposer build. Fixture blocks
146-
// are blank (no real proof to split), so reconstruct structurally
147-
// from the body's aggregation_bits — fork choice reads only the
148-
// participant set, not the proof bytes. The recovered entries go
149-
// straight into the known pool to match the proposer-view store
150-
// the fixtures encode.
151-
if import_ok {
152-
let block = block_data.to_block();
153-
let entries: Vec<(HashedAttestationData, SingleMessageAggregate)> = block
154-
.body
155-
.attestations
156-
.iter()
157-
.map(|att| {
158-
(
159-
HashedAttestationData::new(att.data.clone()),
160-
SingleMessageAggregate::empty(att.aggregation_bits.clone()),
161-
)
162-
})
163-
.collect();
164-
store.insert_known_aggregated_payloads_batch(entries);
165-
// on_block already ran the head update before these votes
166-
// existed; recompute so the head reflects the block's own
167-
// attestations, matching the proposer-view store.
168-
store::update_head(&mut store, false);
169-
}
170-
}
171-
"tick" => {
172-
// Fixtures use either `time` (UNIX seconds) or `interval`
173-
// (absolute interval count since genesis). Interval fixtures
174-
// encode `genesis_time_ms + interval * MILLISECONDS_PER_INTERVAL`.
175-
let timestamp_ms = match (step.time, step.interval) {
176-
(Some(time_s), _) => time_s * 1000,
177-
(None, Some(interval)) => {
178-
genesis_time * 1000 + interval * MILLISECONDS_PER_INTERVAL
179-
}
180-
(None, None) => panic!("tick step missing both time and interval"),
181-
};
182-
let has_proposal = step.has_proposal.unwrap_or(false);
183-
store::on_tick(&mut store, timestamp_ms, has_proposal);
184-
}
185-
"attestation" => {
186-
let att_data = step
187-
.attestation
188-
.expect("attestation step missing attestation data");
189-
let signed_attestation = SignedAttestation {
190-
validator_id: att_data
191-
.validator_id
192-
.expect("attestation step missing validator_id"),
193-
data: att_data.data.into(),
194-
signature: att_data
195-
.signature
196-
.expect("attestation step missing signature"),
197-
};
198-
let is_aggregator = step.is_aggregator.unwrap_or(false);
199-
200-
let result = store::on_gossip_attestation(
201-
&mut store,
202-
&signed_attestation,
203-
is_aggregator,
204-
);
205-
assert_step_outcome(step_idx, step.valid, result)?;
206-
}
207-
"gossipAggregatedAttestation" => {
208-
let att_data = step
209-
.attestation
210-
.expect("gossipAggregatedAttestation step missing attestation data");
211-
let proof_fixture = att_data
212-
.proof
213-
.expect("gossipAggregatedAttestation step missing proof");
214-
let proof_bytes: Vec<u8> = proof_fixture.proof.into();
215-
let proof_data = ByteList::try_from(proof_bytes)
216-
.expect("aggregated proof data fits in ByteList512KiB");
217-
let data: AttestationData = att_data.data.into();
218-
let proof =
219-
SingleMessageAggregate::new(proof_fixture.participants.into(), proof_data);
220-
let aggregated = SignedAggregatedAttestation { data, proof };
221-
222-
let result = if proofs_are_mocked {
223-
store::on_gossip_aggregated_attestation_without_verification(
224-
&mut store, aggregated,
225-
)
226-
} else {
227-
store::on_gossip_aggregated_attestation(&mut store, aggregated)
228-
};
229-
assert_step_outcome(step_idx, step.valid, result)?;
230-
}
231-
other => {
232-
return Err(format!("Unsupported step type '{other}'").into());
233-
}
105+
let filled_block = step.block.as_ref().map(|block_data| block_data.to_block());
106+
if let Some(block_data) = step.block.as_ref()
107+
&& let Some(label) = block_data.block_root_label.as_ref()
108+
{
109+
block_registry.insert(label.clone(), block_data.to_block().hash_tree_root());
234110
}
235111

112+
let result = apply_fork_choice_step(&mut store, &step, Some(proofs_are_mocked));
113+
assert_step_outcome(step_idx, step.valid, result)?;
114+
236115
// Fold this step's blocks into the cumulative tree before checks so
237116
// ancestry walks see blocks finalization may have just pruned from
238117
// the live-chain index (see `all_blocks` above).

0 commit comments

Comments
 (0)