Skip to content

Commit 4659377

Browse files
feat(checkpoint-sync): fetch finalized block during checkpoint-sync (lambdaclass#363)
## Summary - New `fetch_finalized_anchor(url, ...)` issues `/lean/v0/states/finalized` and `/lean/v0/blocks/finalized` in parallel and verifies the pair via the canonical `block.state_root == hash_tree_root(state)` invariant. On mismatch the caller is expected to retry (e.g. the peer advanced finalization between the two requests). - The fetched `SignedBlock` is persisted via `Store::insert_signed_block`, so the local node can serve a valid anchor over `BlocksByRoot` instead of synthesizing a block whose hash differs from `latest_finalized.root` (the scenario leanSpec PR #713 addresses). - The anchor-pair invariant check (`anchor_pair_is_consistent`) is hoisted into `ethlambda_types::state`. `Store::get_forkchoice_store`, the hive test driver, and the new checkpoint-sync client all delegate to it. Notably, the panic path in `get_forkchoice_store` previously checked only header equality + state self-consistency; it now also enforces `block.state_root == hash_tree_root(state)`. ## Compat `--checkpoint-sync-url` is now documented as a base URL (e.g. `http://peer:5052`). The legacy `.../lean/v0/states/finalized` form is still accepted and the trailing path is stripped, so existing devnet scripts keep working. Closes lambdaclass#354. ## Test plan - [x] `cargo clippy --workspace --all-targets -- -D warnings` - [x] `cargo test -p ethlambda` (26 passed) - [x] `cargo test -p ethlambda-rpc --tests` (8 passed, incl. anchor-pair regression coverage) - [x] `cargo test -p ethlambda-storage` (33 passed) - [ ] Smoke test in a multi-node devnet: stop one node, restart it with `--checkpoint-sync-url` pointing at a peer's API, confirm it boots, requests blocks, and stays in sync. --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent fefad42 commit 4659377

7 files changed

Lines changed: 260 additions & 96 deletions

File tree

bin/ethlambda/src/checkpoint_sync.rs

Lines changed: 123 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use std::time::Duration;
22

3+
use ethlambda_types::block::SignedBlock;
34
use ethlambda_types::primitives::HashTreeRoot as _;
4-
use ethlambda_types::state::{State, Validator};
5+
use ethlambda_types::state::{State, Validator, anchor_pair_is_consistent};
56
use libssz::{DecodeError, SszDecode};
67
use reqwest::Client;
78

@@ -13,6 +14,12 @@ const CHECKPOINT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
1314
/// This is an inactivity timeout - it resets on each successful read.
1415
const CHECKPOINT_READ_TIMEOUT: Duration = Duration::from_secs(15);
1516

17+
/// Path of the finalized-state endpoint (relative to the peer's API base URL).
18+
const FINALIZED_STATE_PATH: &str = "/lean/v0/states/finalized";
19+
20+
/// Path of the finalized-block endpoint (relative to the peer's API base URL).
21+
const FINALIZED_BLOCK_PATH: &str = "/lean/v0/blocks/finalized";
22+
1623
#[derive(Debug, thiserror::Error)]
1724
pub enum CheckpointSyncError {
1825
#[error("HTTP request failed: {0}")]
@@ -49,9 +56,11 @@ pub enum CheckpointSyncError {
4956
BlockHeaderFinalizedRootMismatch,
5057
#[error("block header at justified slot must match justified root")]
5158
BlockHeaderJustifiedRootMismatch,
59+
#[error("anchor block does not match anchor state")]
60+
AnchorPairingMismatch,
5261
}
5362

54-
/// Fetch finalized state from checkpoint sync URL.
63+
/// Build the HTTP client used for checkpoint sync fetches.
5564
///
5665
/// Uses two-phase timeout strategy:
5766
/// - Connect timeout (15s): Fails quickly if peer is unreachable
@@ -63,33 +72,101 @@ pub enum CheckpointSyncError {
6372
/// failing fast if the connection stalls. A plain total timeout would
6473
/// disconnect even for valid downloads if the state is simply too large to
6574
/// transfer within the time limit.
66-
pub async fn fetch_checkpoint_state(
67-
url: &str,
68-
expected_genesis_time: u64,
69-
expected_validators: &[Validator],
70-
) -> Result<State, CheckpointSyncError> {
71-
// Use .read_timeout() to detect stalled downloads (inactivity timer).
72-
// This allows large states to complete as long as data keeps flowing.
73-
let client = Client::builder()
75+
fn build_client() -> Result<Client, CheckpointSyncError> {
76+
Ok(Client::builder()
7477
.connect_timeout(CHECKPOINT_CONNECT_TIMEOUT)
7578
.read_timeout(CHECKPOINT_READ_TIMEOUT)
76-
.build()?;
79+
.build()?)
80+
}
7781

78-
let response = client
82+
/// Fetch and SSZ-decode an `application/octet-stream` body from `url`.
83+
async fn fetch_ssz<T: SszDecode>(client: &Client, url: &str) -> Result<T, CheckpointSyncError> {
84+
let bytes = client
7985
.get(url)
8086
.header("Accept", "application/octet-stream")
8187
.send()
8288
.await?
83-
.error_for_status()?;
89+
.error_for_status()?
90+
.bytes()
91+
.await?;
8492

85-
let bytes = response.bytes().await?;
86-
let state = State::from_ssz_bytes(&bytes).map_err(CheckpointSyncError::SszDecode)?;
93+
T::from_ssz_bytes(&bytes).map_err(CheckpointSyncError::SszDecode)
94+
}
8795

88-
verify_checkpoint_state(&state, expected_genesis_time, expected_validators)?;
96+
/// Normalize a checkpoint-sync URL to a base URL.
97+
///
98+
/// Operators historically pass the full state URL (e.g.
99+
/// `http://peer:5052/lean/v0/states/finalized`) via `--checkpoint-sync-url`.
100+
/// The new contract is a base URL (`http://peer:5052`) so we can derive both
101+
/// the state and block endpoints. To avoid breaking existing devnet scripts,
102+
/// strip a trailing legacy path if present and also trim any trailing slash.
103+
// TODO: remove this and use the full URL
104+
fn normalize_base_url(url: &str) -> &str {
105+
// Trim trailing slashes FIRST so that the legacy-suffix strip succeeds on
106+
// inputs like `…/lean/v0/states/finalized/`; otherwise we'd leave the
107+
// state path embedded in the "base URL" and double-prefix every request.
108+
let trimmed = url.trim_end_matches('/');
109+
trimmed
110+
.strip_suffix(FINALIZED_STATE_PATH)
111+
.unwrap_or(trimmed)
112+
}
89113

114+
/// Fetch the finalized state from a checkpoint peer and verify it
115+
/// against the local genesis configuration.
116+
async fn fetch_finalized_state(
117+
client: &Client,
118+
base_url: &str,
119+
expected_genesis_time: u64,
120+
expected_validators: &[Validator],
121+
) -> Result<State, CheckpointSyncError> {
122+
let url = format!("{base_url}{FINALIZED_STATE_PATH}");
123+
let state: State = fetch_ssz(client, &url).await?;
124+
verify_checkpoint_state(&state, expected_genesis_time, expected_validators)?;
90125
Ok(state)
91126
}
92127

128+
/// Fetch the finalized signed block from a checkpoint peer.
129+
async fn fetch_finalized_block(
130+
client: &Client,
131+
base_url: &str,
132+
) -> Result<SignedBlock, CheckpointSyncError> {
133+
let url = format!("{base_url}{FINALIZED_BLOCK_PATH}");
134+
fetch_ssz(client, &url).await
135+
}
136+
137+
/// Fetch the finalized state and signed block in parallel and verify they pair.
138+
///
139+
/// If the peer advances finalization between the two requests the pairing will
140+
/// not hold; the caller is expected to retry.
141+
pub async fn fetch_finalized_anchor(
142+
url: &str,
143+
expected_genesis_time: u64,
144+
expected_validators: &[Validator],
145+
) -> Result<(State, SignedBlock), CheckpointSyncError> {
146+
let base_url = normalize_base_url(url);
147+
let client = build_client()?;
148+
149+
// Issue both fetches concurrently; either failure cancels the pair.
150+
let (mut state, signed_block) = tokio::try_join!(
151+
fetch_finalized_state(
152+
&client,
153+
base_url,
154+
expected_genesis_time,
155+
expected_validators
156+
),
157+
fetch_finalized_block(&client, base_url),
158+
)?;
159+
160+
// Strictly mirrors the invariants `Store::get_forkchoice_store` asserts —
161+
// header equality, state self-consistency, and `block.state_root` equal
162+
// to the canonical tree-hash root of the state.
163+
if !anchor_pair_is_consistent(&mut state, &signed_block.message) {
164+
return Err(CheckpointSyncError::AnchorPairingMismatch);
165+
}
166+
167+
Ok((state, signed_block))
168+
}
169+
93170
/// Verify checkpoint state is structurally valid.
94171
///
95172
/// Arguments:
@@ -417,4 +494,34 @@ mod tests {
417494
state.latest_justified.root = H256::from([99u8; 32]); // Wrong root
418495
assert!(verify_checkpoint_state(&state, 1000, &validators).is_err());
419496
}
497+
498+
// --- normalize_base_url ---
499+
500+
#[test]
501+
fn normalize_strips_legacy_state_path() {
502+
assert_eq!(
503+
normalize_base_url("http://peer:5052/lean/v0/states/finalized"),
504+
"http://peer:5052"
505+
);
506+
}
507+
508+
#[test]
509+
fn normalize_passes_through_base_url() {
510+
assert_eq!(normalize_base_url("http://peer:5052"), "http://peer:5052");
511+
}
512+
513+
#[test]
514+
fn normalize_strips_trailing_slash() {
515+
assert_eq!(normalize_base_url("http://peer:5052/"), "http://peer:5052");
516+
}
517+
518+
#[test]
519+
fn normalize_strips_legacy_state_path_with_trailing_slash() {
520+
// Regression: a trailing slash on the legacy path used to defeat
521+
// strip_suffix, leaving the path embedded in the "base URL".
522+
assert_eq!(
523+
normalize_base_url("http://peer:5052/lean/v0/states/finalized/"),
524+
"http://peer:5052"
525+
);
526+
}
420527
}

bin/ethlambda/src/main.rs

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use clap::Parser;
2222
use ethlambda_blockchain::key_manager::ValidatorKeyPair;
2323
use ethlambda_network_api::{InitBlockChain, InitP2P, ToBlockChainToP2PRef, ToP2PToBlockChainRef};
2424
use ethlambda_p2p::{Bootnode, P2P, PeerId, SwarmConfig, build_swarm, parse_enrs};
25-
use ethlambda_types::primitives::H256;
25+
use ethlambda_types::primitives::{H256, HashTreeRoot as _};
2626
use ethlambda_types::{
2727
aggregator::AggregatorController,
2828
genesis::GenesisConfig,
@@ -76,8 +76,12 @@ struct CliOptions {
7676
/// The node ID to look up in annotated_validators.yaml (e.g., "ethlambda_0")
7777
#[arg(long)]
7878
node_id: String,
79-
/// URL to download checkpoint state from (e.g., http://peer:5052/lean/v0/states/finalized)
80-
/// When set, skips genesis initialization and syncs from checkpoint.
79+
/// Base URL of a checkpoint-sync peer's API server (e.g., http://peer:5052).
80+
/// When set, skips genesis initialization and fetches the finalized state
81+
/// and block from the peer's `/lean/v0/states/finalized` and
82+
/// `/lean/v0/blocks/finalized` endpoints. For backward compatibility, a
83+
/// URL ending in `/lean/v0/states/finalized` is accepted and the trailing
84+
/// path is stripped.
8185
#[arg(long)]
8286
checkpoint_sync_url: Option<String>,
8387
/// Whether this node acts as a committee aggregator.
@@ -557,14 +561,19 @@ fn read_hex_file_bytes(path: impl AsRef<Path>) -> Vec<u8> {
557561
/// Fetch the initial state for the node.
558562
///
559563
/// If `checkpoint_url` is provided, performs checkpoint sync by downloading
560-
/// and verifying the finalized state from a remote peer. Otherwise, creates
561-
/// a genesis state from the local genesis configuration.
564+
/// and verifying the finalized state AND signed block in parallel from a
565+
/// remote peer. Otherwise, creates a genesis state from the local genesis
566+
/// configuration.
567+
///
568+
/// Fetching the matching signed block lets the local store serve a valid
569+
/// anchor via the `BlocksByRoot` req-resp protocol; without it, peers
570+
/// requesting the anchor would receive a synthetic block whose hash differs
571+
/// from `latest_finalized.root` and would score-penalize us.
562572
///
563573
/// # Arguments
564574
///
565-
/// * `checkpoint_url` - Optional URL to fetch checkpoint state from
575+
/// * `checkpoint_url` - Optional base URL to a peer's API server
566576
/// * `genesis` - Genesis configuration (for genesis_time verification and genesis state creation)
567-
/// * `validators` - Validator set (moved for genesis state creation)
568577
/// * `backend` - Storage backend for Store creation
569578
///
570579
/// # Returns
@@ -587,19 +596,53 @@ async fn fetch_initial_state(
587596
// Checkpoint sync path
588597
info!(%checkpoint_url, "Starting checkpoint sync");
589598

590-
let state =
591-
checkpoint_sync::fetch_checkpoint_state(checkpoint_url, genesis.genesis_time, &validators)
592-
.await?;
599+
// The state and block are fetched in parallel; if the peer advances
600+
// finalization between the two requests the pair won't match. Retry a
601+
// small number of times so this transient race doesn't fail node startup.
602+
const MAX_ANCHOR_FETCH_ATTEMPTS: u32 = 3;
603+
const ANCHOR_FETCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
604+
605+
let mut attempt = 1;
606+
let (state, signed_block) = loop {
607+
match checkpoint_sync::fetch_finalized_anchor(
608+
checkpoint_url,
609+
genesis.genesis_time,
610+
&validators,
611+
)
612+
.await
613+
{
614+
Ok(pair) => break pair,
615+
Err(checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch)
616+
if attempt < MAX_ANCHOR_FETCH_ATTEMPTS =>
617+
{
618+
warn!(
619+
attempt,
620+
max = MAX_ANCHOR_FETCH_ATTEMPTS,
621+
"Anchor state and block disagree (peer likely advanced finalization mid-fetch); retrying"
622+
);
623+
tokio::time::sleep(ANCHOR_FETCH_RETRY_DELAY).await;
624+
attempt += 1;
625+
}
626+
Err(err) => return Err(err),
627+
}
628+
};
593629

594630
info!(
595631
slot = state.slot,
596632
validators = state.validators.len(),
597633
finalized_slot = state.latest_finalized.slot,
634+
anchor_block_slot = signed_block.message.slot,
598635
"Checkpoint sync complete"
599636
);
600637

601-
// Store the anchor state and header, without body
602-
Ok(Store::from_anchor_state(backend, state))
638+
// Initialize the store from state + anchor block body, then persist the
639+
// signatures so we can serve the anchor on BlocksByRoot. `insert_signed_block`
640+
// overlaps with what `get_forkchoice_store` already wrote, but it's
641+
// idempotent and the only path that also stores `BlockSignatures`.
642+
let anchor_root = signed_block.message.header().hash_tree_root();
643+
let mut store = Store::get_forkchoice_store(backend, state, signed_block.message.clone());
644+
store.insert_signed_block(anchor_root, signed_block);
645+
Ok(store)
603646
}
604647

605648
#[cfg(test)]

crates/blockchain/src/store.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,17 +1610,11 @@ mod tests {
16101610
use std::sync::Arc;
16111611

16121612
let genesis_state = State::from_genesis(1000, vec![]);
1613-
let genesis_block = Block {
1614-
slot: 0,
1615-
proposer_index: 0,
1616-
parent_root: H256::ZERO,
1617-
state_root: H256::ZERO,
1618-
body: BlockBody {
1619-
attestations: AggregatedAttestations::default(),
1620-
},
1621-
};
16221613
let backend = Arc::new(InMemoryBackend::new());
1623-
let mut store = Store::get_forkchoice_store(backend, genesis_state, genesis_block);
1614+
// Use `from_anchor_state` here rather than `get_forkchoice_store`:
1615+
// the latter now enforces `block.state_root == hash_tree_root(state)`,
1616+
// which a synthetic genesis block with zero state_root cannot satisfy.
1617+
let mut store = Store::from_anchor_state(backend, genesis_state);
16241618

16251619
let head_root = store.head();
16261620
let att_data = AttestationData {

crates/blockchain/tests/forkchoice_spectests.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use ethlambda_types::{
1010
attestation::{AttestationData, SignedAggregatedAttestation, SignedAttestation},
1111
block::{AggregatedSignatureProof, Block},
1212
primitives::{ByteList, H256, HashTreeRoot as _},
13-
state::State,
13+
state::{State, anchor_pair_is_consistent},
1414
};
1515

1616
use ethlambda_test_fixtures::fork_choice::{AttestationCheck, ForkChoiceTestVector, StoreChecks};
@@ -39,10 +39,36 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
3939
}
4040
println!("Running test: {}", name);
4141

42-
// Initialize store from anchor state/block
43-
let anchor_state: State = test.anchor_state.into();
42+
// Initialize store from anchor state/block.
43+
//
44+
// Fixtures whose `steps` is empty are "anchor rejection" cases (e.g.
45+
// `test_store_from_anchor_rejects_mismatched_state_root`): they assert
46+
// that init refuses an inconsistent (state, block) pair. We detect that
47+
// up front with the non-panicking helper instead of letting
48+
// `get_forkchoice_store`'s assert! panic out of the test harness.
49+
let mut anchor_state: State = test.anchor_state.into();
4450
let anchor_block: Block = test.anchor_block.into();
4551
let genesis_time = anchor_state.config.genesis_time;
52+
53+
let pair_ok = anchor_pair_is_consistent(&mut anchor_state, &anchor_block);
54+
if test.steps.is_empty() {
55+
if pair_ok {
56+
return Err(format!(
57+
"Fixture '{name}' has no steps (expects anchor rejection) \
58+
but the (state, block) pair is consistent"
59+
)
60+
.into());
61+
}
62+
continue;
63+
}
64+
if !pair_ok {
65+
return Err(format!(
66+
"Fixture '{name}' has steps (expects anchor acceptance) \
67+
but the (state, block) pair is inconsistent"
68+
)
69+
.into());
70+
}
71+
4672
let backend = Arc::new(InMemoryBackend::new());
4773
let mut store = Store::get_forkchoice_store(backend, anchor_state, anchor_block);
4874

0 commit comments

Comments
 (0)