From da74f87690645a25a544596331698f417bb04861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:51:59 -0300 Subject: [PATCH 01/13] feat: add checkpoint-sync support --- Cargo.lock | 101 ++++++++++ Cargo.toml | 1 + bin/ethlambda/Cargo.toml | 2 + bin/ethlambda/src/checkpoint_sync.rs | 283 +++++++++++++++++++++++++++ bin/ethlambda/src/main.rs | 45 ++++- 5 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 bin/ethlambda/src/checkpoint_sync.rs diff --git a/Cargo.lock b/Cargo.lock index 7558bbf..2f2dc77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1946,10 +1946,12 @@ dependencies = [ "ethlambda-storage", "ethlambda-types", "hex", + "reqwest", "serde", "serde_yaml_ng", "spawned-concurrency", "spawned-rt", + "thiserror 2.0.17", "tokio", "tracing", "tracing-subscriber", @@ -2956,12 +2958,30 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.5", +] + [[package]] name = "hyper-util" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", "futures-channel", "futures-core", @@ -2969,7 +2989,9 @@ dependencies = [ "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2 0.6.1", "tokio", @@ -3225,6 +3247,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -5972,6 +6004,44 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.5", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -6811,6 +6881,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -7046,6 +7119,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -7118,6 +7201,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/Cargo.toml b/Cargo.toml index 07efe5e..92c8100 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,3 +70,4 @@ vergen-git2 = { version = "9", features = ["rustc"] } rand = "0.9" rocksdb = "0.24" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 7300f93..3601d73 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -24,6 +24,8 @@ serde_yaml_ng.workspace = true hex.workspace = true clap.workspace = true +reqwest.workspace = true +thiserror.workspace = true [build-dependencies] vergen-git2.workspace = true diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs new file mode 100644 index 0000000..3e25a9c --- /dev/null +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -0,0 +1,283 @@ +use std::time::Duration; + +use ethlambda_types::block::{Block, BlockBody}; +use ethlambda_types::primitives::Decode; +use ethlambda_types::state::{State, Validator}; +use reqwest::Client; + +const CHECKPOINT_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_STATE_SIZE: u64 = 100 * 1024 * 1024; // 100 MB limit + +#[derive(Debug, thiserror::Error)] +pub enum CheckpointSyncError { + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("SSZ deserialization failed: {0}")] + Ssz(String), + #[error("Verification failed: {0}")] + Verification(String), +} + +/// Fetch finalized state from checkpoint sync URL. +pub async fn fetch_checkpoint_state(base_url: &str) -> Result { + let url = format!( + "{}/lean/v0/states/finalized", + base_url.trim_end_matches('/') + ); + let client = Client::builder().timeout(CHECKPOINT_TIMEOUT).build()?; + + let response = client + .get(&url) + .header("Accept", "application/octet-stream") + .send() + .await? + .error_for_status()?; + + // DoS protection: Check Content-Length before reading + if let Some(content_length) = response.content_length() + && content_length > MAX_STATE_SIZE + { + return Err(CheckpointSyncError::Verification(format!( + "state too large: {} bytes (max {})", + content_length, MAX_STATE_SIZE + ))); + } + + let bytes = response.bytes().await?; + if bytes.len() as u64 > MAX_STATE_SIZE { + return Err(CheckpointSyncError::Verification( + "state exceeds size limit".into(), + )); + } + + State::from_ssz_bytes(&bytes).map_err(|e| CheckpointSyncError::Ssz(format!("{:?}", e))) +} + +/// Verify checkpoint state is structurally valid. +/// +/// Arguments: +/// - state: The downloaded checkpoint state +/// - expected_genesis_time: Genesis time from local config +/// - expected_validators: Validator pubkeys from local genesis config +pub fn verify_checkpoint_state( + state: &State, + expected_genesis_time: u64, + expected_validators: &[Validator], +) -> Result<(), CheckpointSyncError> { + // Slot sanity check + if state.slot == 0 { + return Err(CheckpointSyncError::Verification("slot cannot be 0".into())); + } + + // Validators exist + if state.validators.is_empty() { + return Err(CheckpointSyncError::Verification("no validators".into())); + } + + // Genesis time matches + if state.config.genesis_time != expected_genesis_time { + return Err(CheckpointSyncError::Verification(format!( + "genesis time mismatch: expected {}, got {}", + expected_genesis_time, state.config.genesis_time + ))); + } + + // Validator count matches + if state.validators.len() != expected_validators.len() { + return Err(CheckpointSyncError::Verification(format!( + "validator count mismatch: expected {}, got {}", + expected_validators.len(), + state.validators.len() + ))); + } + + // Validator pubkeys match (critical security check) + for (i, (state_val, expected_val)) in state + .validators + .iter() + .zip(expected_validators.iter()) + .enumerate() + { + if state_val.pubkey != expected_val.pubkey { + return Err(CheckpointSyncError::Verification(format!( + "validator {} pubkey mismatch", + i + ))); + } + } + + // Finalized slot sanity + if state.latest_finalized.slot > state.slot { + return Err(CheckpointSyncError::Verification( + "finalized slot cannot exceed state slot".into(), + )); + } + + // Justified must be at or after finalized + if state.latest_justified.slot < state.latest_finalized.slot { + return Err(CheckpointSyncError::Verification( + "justified slot cannot precede finalized slot".into(), + )); + } + + // Block header slot consistency + if state.latest_block_header.slot > state.slot { + return Err(CheckpointSyncError::Verification( + "block header slot exceeds state slot".into(), + )); + } + + Ok(()) +} + +/// Construct anchor block from checkpoint state. +/// +/// IMPORTANT: This creates a block with default body. The block's tree_hash_root() +/// will only match the original block if the original also had an empty body. +/// For most checkpoint states, this is acceptable because fork choice uses the +/// anchor checkpoint, not individual block lookups. +pub fn construct_anchor_block(state: &State) -> Block { + Block { + slot: state.latest_block_header.slot, + parent_root: state.latest_block_header.parent_root, + proposer_index: state.latest_block_header.proposer_index, + state_root: state.latest_block_header.state_root, + body: BlockBody::default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::block::BlockHeader; + use ethlambda_types::primitives::VariableList; + use ethlambda_types::state::{ChainConfig, Checkpoint}; + + // Helper to create valid test state + fn create_test_state(slot: u64, validators: Vec, genesis_time: u64) -> State { + use ethlambda_types::primitives::H256; + use ethlambda_types::state::{JustificationValidators, JustifiedSlots}; + + State { + slot, + validators: VariableList::new(validators).unwrap(), + latest_block_header: BlockHeader { + slot, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body_root: H256::ZERO, + proposer_index: 0, + }, + latest_justified: Checkpoint { + slot: slot.saturating_sub(10), + root: H256::ZERO, + }, + latest_finalized: Checkpoint { + slot: slot.saturating_sub(20), + root: H256::ZERO, + }, + config: ChainConfig { genesis_time }, + historical_block_hashes: Default::default(), + justified_slots: JustifiedSlots::with_capacity(0).unwrap(), + justifications_roots: Default::default(), + justifications_validators: JustificationValidators::with_capacity(0).unwrap(), + } + } + + fn create_test_validator() -> Validator { + Validator { + pubkey: [1u8; 52], + index: 0, + } + } + + fn create_different_validator() -> Validator { + Validator { + pubkey: [2u8; 52], + index: 0, + } + } + + #[test] + fn verify_accepts_valid_state() { + let validators = vec![create_test_validator()]; + let state = create_test_state(100, validators.clone(), 1000); + assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + } + + #[test] + fn verify_rejects_slot_zero() { + let validators = vec![create_test_validator()]; + let state = create_test_state(0, validators.clone(), 1000); + assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + } + + #[test] + fn verify_rejects_empty_validators() { + let state = create_test_state(100, vec![], 1000); + assert!(verify_checkpoint_state(&state, 1000, &[]).is_err()); + } + + #[test] + fn verify_rejects_genesis_time_mismatch() { + let validators = vec![create_test_validator()]; + let state = create_test_state(100, validators.clone(), 1000); + // State has genesis_time=1000, we pass expected=9999 + assert!(verify_checkpoint_state(&state, 9999, &validators).is_err()); + } + + #[test] + fn verify_rejects_validator_count_mismatch() { + let validators = vec![create_test_validator()]; + let state = create_test_state(100, validators.clone(), 1000); + let extra_validators = vec![create_test_validator(), create_test_validator()]; + assert!(verify_checkpoint_state(&state, 1000, &extra_validators).is_err()); + } + + #[test] + fn verify_rejects_validator_pubkey_mismatch() { + let validators = vec![create_test_validator()]; + let state = create_test_state(100, validators.clone(), 1000); + let different_validators = vec![create_different_validator()]; + assert!(verify_checkpoint_state(&state, 1000, &different_validators).is_err()); + } + + #[test] + fn verify_rejects_finalized_after_state_slot() { + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_finalized.slot = 101; // Finalized after state slot + assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + } + + #[test] + fn verify_rejects_justified_before_finalized() { + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_finalized.slot = 50; + state.latest_justified.slot = 40; // Justified before finalized + assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + } + + #[test] + fn verify_rejects_block_header_slot_exceeds_state() { + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_block_header.slot = 101; // Block header slot exceeds state slot + assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + } + + #[test] + fn construct_anchor_block_copies_header_fields() { + let validators = vec![create_test_validator()]; + let state = create_test_state(100, validators, 1000); + let block = construct_anchor_block(&state); + assert_eq!(block.slot, state.latest_block_header.slot); + assert_eq!(block.parent_root, state.latest_block_header.parent_root); + assert_eq!( + block.proposer_index, + state.latest_block_header.proposer_index + ); + assert_eq!(block.state_root, state.latest_block_header.state_root); + } +} diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index a906f71..40e0a5d 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -1,3 +1,4 @@ +mod checkpoint_sync; mod version; use std::{ @@ -46,6 +47,10 @@ struct CliOptions { /// The node ID to look up in annotated_validators.yaml (e.g., "ethlambda_0") #[arg(long)] node_id: String, + /// URL of a peer to download checkpoint state from (e.g., http://peer:5052) + /// When set, skips genesis initialization and syncs from checkpoint. + #[arg(long)] + checkpoint_sync_url: Option, } #[tokio::main] @@ -99,9 +104,45 @@ async fn main() { let validator_keys = read_validator_keys(&validators_path, &validator_keys_dir, &options.node_id); - let genesis_state = State::from_genesis(genesis_config.genesis_time, validators); let backend = Arc::new(RocksDBBackend::open("./data").expect("Failed to open RocksDB")); - let store = Store::from_anchor_state(backend, genesis_state); + + let store = if let Some(checkpoint_url) = &options.checkpoint_sync_url { + // Checkpoint sync path + info!(%checkpoint_url, "Starting checkpoint sync"); + + let state = match checkpoint_sync::fetch_checkpoint_state(checkpoint_url).await { + Ok(state) => state, + Err(e) => { + error!(%checkpoint_url, %e, "Checkpoint sync failed"); + std::process::exit(1); + } + }; + + // Verify against local genesis config + if let Err(e) = checkpoint_sync::verify_checkpoint_state( + &state, + genesis.config.genesis_time, + &validators, + ) { + error!(%e, "Checkpoint state verification failed"); + std::process::exit(1); + } + + let anchor_block = checkpoint_sync::construct_anchor_block(&state); + + info!( + slot = state.slot, + validators = state.validators.len(), + finalized_slot = state.latest_finalized.slot, + "Checkpoint sync complete" + ); + + Store::get_forkchoice_store(backend, state, anchor_block) + } else { + // Genesis path (existing code) + let genesis_state = State::from_genesis(genesis_config.genesis_time, validators); + Store::from_anchor_state(backend, genesis_state) + }; let (p2p_tx, p2p_rx) = tokio::sync::mpsc::unbounded_channel(); let blockchain = BlockChain::spawn(store.clone(), p2p_tx, validator_keys); From 2964e20d4580bd33fc52e2801306f650ac8b9ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:47:23 -0300 Subject: [PATCH 02/13] fix: update import path --- bin/ethlambda/src/checkpoint_sync.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 3e25a9c..8cedb82 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -1,7 +1,7 @@ use std::time::Duration; use ethlambda_types::block::{Block, BlockBody}; -use ethlambda_types::primitives::Decode; +use ethlambda_types::primitives::ssz::Decode; use ethlambda_types::state::{State, Validator}; use reqwest::Client; From 912dafa233be36d6bf7332e94caf4f606c7b5126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:46:35 -0300 Subject: [PATCH 03/13] feat: use read and connect timeouts instead of e2e --- bin/ethlambda/src/checkpoint_sync.rs | 33 ++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 8cedb82..1929375 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -5,8 +5,13 @@ use ethlambda_types::primitives::ssz::Decode; use ethlambda_types::state::{State, Validator}; use reqwest::Client; -const CHECKPOINT_TIMEOUT: Duration = Duration::from_secs(60); -const MAX_STATE_SIZE: u64 = 100 * 1024 * 1024; // 100 MB limit +/// Timeout for establishing the HTTP connection to the checkpoint peer. +/// Fail fast if the peer is unreachable. +const CHECKPOINT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// Timeout for reading data during body download. +/// This is an inactivity timeout - it resets on each successful read. +const CHECKPOINT_READ_TIMEOUT: Duration = Duration::from_secs(15); #[derive(Debug, thiserror::Error)] pub enum CheckpointSyncError { @@ -19,12 +24,26 @@ pub enum CheckpointSyncError { } /// Fetch finalized state from checkpoint sync URL. +/// +/// Uses two-phase timeout strategy: +/// - Connect timeout (15s): Fails quickly if peer is unreachable +/// - Read timeout (15s): Inactivity timeout that resets on each read +/// +/// Note: We use a read timeout (via `.read_timeout()`) instead of a total download +/// timeout to automatically detect stalled downloads. This allows large states +/// to be downloaded successfully as long as data keeps flowing, while still +/// failing fast if the connection stalls. A plain total timeout would +/// disconnect even for valid downloads if the state is simply too large to +/// transfer within the time limit. pub async fn fetch_checkpoint_state(base_url: &str) -> Result { - let url = format!( - "{}/lean/v0/states/finalized", - base_url.trim_end_matches('/') - ); - let client = Client::builder().timeout(CHECKPOINT_TIMEOUT).build()?; + let base_url = base_url.trim_end_matches('/'); + let url = format!("{base_url}/lean/v0/states/finalized"); + // Use .read_timeout() to detect stalled downloads (inactivity timer). + // This allows large states to complete as long as data keeps flowing. + let client = Client::builder() + .connect_timeout(CHECKPOINT_CONNECT_TIMEOUT) + .read_timeout(CHECKPOINT_READ_TIMEOUT) + .build()?; let response = client .get(&url) From 11a6d9ba54560ad4df8dcd4aa695423d3a501eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:47:04 -0300 Subject: [PATCH 04/13] fix: remove download size limit --- bin/ethlambda/src/checkpoint_sync.rs | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 1929375..863767b 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -1,7 +1,7 @@ use std::time::Duration; use ethlambda_types::block::{Block, BlockBody}; -use ethlambda_types::primitives::ssz::Decode; +use ethlambda_types::primitives::ssz::{Decode, TreeHash}; use ethlambda_types::state::{State, Validator}; use reqwest::Client; @@ -52,23 +52,7 @@ pub async fn fetch_checkpoint_state(base_url: &str) -> Result MAX_STATE_SIZE - { - return Err(CheckpointSyncError::Verification(format!( - "state too large: {} bytes (max {})", - content_length, MAX_STATE_SIZE - ))); - } - let bytes = response.bytes().await?; - if bytes.len() as u64 > MAX_STATE_SIZE { - return Err(CheckpointSyncError::Verification( - "state exceeds size limit".into(), - )); - } - State::from_ssz_bytes(&bytes).map_err(|e| CheckpointSyncError::Ssz(format!("{:?}", e))) } From 55e180f27263cc914d04386c60c86e62c0d10303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:47:32 -0300 Subject: [PATCH 05/13] feat: add additional checks --- bin/ethlambda/src/checkpoint_sync.rs | 143 ++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 863767b..f25c4d5 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -94,6 +94,16 @@ pub fn verify_checkpoint_state( ))); } + // Validator indices are sequential (0, 1, 2, ...) + for (position, validator) in state.validators.iter().enumerate() { + if validator.index != position as u64 { + return Err(CheckpointSyncError::Verification(format!( + "validator at position {} has index {} (expected {})", + position, validator.index, position + ))); + } + } + // Validator pubkeys match (critical security check) for (i, (state_val, expected_val)) in state .validators @@ -123,6 +133,15 @@ pub fn verify_checkpoint_state( )); } + // If justified and finalized are at same slot, roots must match + if state.latest_justified.slot == state.latest_finalized.slot + && state.latest_justified.root != state.latest_finalized.root + { + return Err(CheckpointSyncError::Verification( + "justified and finalized at same slot must have matching roots".into(), + )); + } + // Block header slot consistency if state.latest_block_header.slot > state.slot { return Err(CheckpointSyncError::Verification( @@ -130,6 +149,25 @@ pub fn verify_checkpoint_state( )); } + // If block header matches checkpoint slots, roots must match + let block_root = state.latest_block_header.tree_hash_root(); + + if state.latest_block_header.slot == state.latest_finalized.slot + && block_root != state.latest_finalized.root.0 + { + return Err(CheckpointSyncError::Verification( + "block header at finalized slot must match finalized root".into(), + )); + } + + if state.latest_block_header.slot == state.latest_justified.slot + && block_root != state.latest_justified.root.0 + { + return Err(CheckpointSyncError::Verification( + "block header at justified slot must match justified root".into(), + )); + } + Ok(()) } @@ -201,6 +239,15 @@ mod tests { } } + fn create_validators_with_indices(count: usize) -> Vec { + (0..count) + .map(|i| Validator { + pubkey: [i as u8 + 1; 52], + index: i as u64, + }) + .collect() + } + #[test] fn verify_accepts_valid_state() { let validators = vec![create_test_validator()]; @@ -233,10 +280,35 @@ mod tests { fn verify_rejects_validator_count_mismatch() { let validators = vec![create_test_validator()]; let state = create_test_state(100, validators.clone(), 1000); - let extra_validators = vec![create_test_validator(), create_test_validator()]; + let extra_validators = create_validators_with_indices(2); assert!(verify_checkpoint_state(&state, 1000, &extra_validators).is_err()); } + #[test] + fn verify_accepts_multiple_validators_with_sequential_indices() { + let validators = create_validators_with_indices(3); + let state = create_test_state(100, validators.clone(), 1000); + assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + } + + #[test] + fn verify_rejects_non_sequential_validator_indices() { + let mut validators = create_validators_with_indices(3); + validators[1].index = 5; // Wrong index at position 1 + let state = create_test_state(100, validators.clone(), 1000); + let expected_validators = create_validators_with_indices(3); + assert!(verify_checkpoint_state(&state, 1000, &expected_validators).is_err()); + } + + #[test] + fn verify_rejects_duplicate_validator_indices() { + let mut validators = create_validators_with_indices(3); + validators[2].index = 0; // Duplicate index + let state = create_test_state(100, validators.clone(), 1000); + let expected_validators = create_validators_with_indices(3); + assert!(verify_checkpoint_state(&state, 1000, &expected_validators).is_err()); + } + #[test] fn verify_rejects_validator_pubkey_mismatch() { let validators = vec![create_test_validator()]; @@ -262,6 +334,31 @@ mod tests { assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); } + #[test] + fn verify_accepts_justified_equals_finalized_with_matching_roots() { + use ethlambda_types::primitives::H256; + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + let common_root = H256::from([42u8; 32]); + state.latest_finalized.slot = 50; + state.latest_finalized.root = common_root; + state.latest_justified.slot = 50; // Same slot + state.latest_justified.root = common_root; // Same root + assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + } + + #[test] + fn verify_rejects_justified_equals_finalized_with_different_roots() { + use ethlambda_types::primitives::H256; + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_finalized.slot = 50; + state.latest_finalized.root = H256::from([1u8; 32]); + state.latest_justified.slot = 50; // Same slot + state.latest_justified.root = H256::from([2u8; 32]); // Different root - conflict! + assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + } + #[test] fn verify_rejects_block_header_slot_exceeds_state() { let validators = vec![create_test_validator()]; @@ -270,6 +367,50 @@ mod tests { assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); } + #[test] + fn verify_accepts_block_header_matches_finalized_with_correct_root() { + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_block_header.slot = 50; + let block_root = state.latest_block_header.tree_hash_root(); + state.latest_finalized.slot = 50; + state.latest_finalized.root = block_root; + assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + } + + #[test] + fn verify_rejects_block_header_matches_finalized_with_wrong_root() { + use ethlambda_types::primitives::H256; + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_block_header.slot = 50; + state.latest_finalized.slot = 50; + state.latest_finalized.root = H256::from([99u8; 32]); // Wrong root + assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + } + + #[test] + fn verify_accepts_block_header_matches_justified_with_correct_root() { + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_block_header.slot = 90; + let block_root = state.latest_block_header.tree_hash_root(); + state.latest_justified.slot = 90; + state.latest_justified.root = block_root; + assert!(verify_checkpoint_state(&state, 1000, &validators).is_ok()); + } + + #[test] + fn verify_rejects_block_header_matches_justified_with_wrong_root() { + use ethlambda_types::primitives::H256; + let validators = vec![create_test_validator()]; + let mut state = create_test_state(100, validators.clone(), 1000); + state.latest_block_header.slot = 90; + state.latest_justified.slot = 90; + state.latest_justified.root = H256::from([99u8; 32]); // Wrong root + assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); + } + #[test] fn construct_anchor_block_copies_header_fields() { let validators = vec![create_test_validator()]; From c3892d17719fc2662493922c67542f2d10145674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:51:31 -0300 Subject: [PATCH 06/13] refactor: clean up the main entrypoint --- bin/ethlambda/src/main.rs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 40e0a5d..2974be8 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -110,21 +110,20 @@ async fn main() { // Checkpoint sync path info!(%checkpoint_url, "Starting checkpoint sync"); - let state = match checkpoint_sync::fetch_checkpoint_state(checkpoint_url).await { - Ok(state) => state, - Err(e) => { - error!(%checkpoint_url, %e, "Checkpoint sync failed"); - std::process::exit(1); - } + let Ok(state) = checkpoint_sync::fetch_checkpoint_state(checkpoint_url) + .await + .inspect_err(|err| error!(%checkpoint_url, %err, "Checkpoint sync failed")) + else { + std::process::exit(1); }; + let genesis_time = genesis.config.genesis_time; + // Verify against local genesis config - if let Err(e) = checkpoint_sync::verify_checkpoint_state( - &state, - genesis.config.genesis_time, - &validators, - ) { - error!(%e, "Checkpoint state verification failed"); + if let Err(err) = + checkpoint_sync::verify_checkpoint_state(&state, genesis_time, &validators) + { + error!(%err, "Checkpoint state verification failed"); std::process::exit(1); } @@ -139,7 +138,6 @@ async fn main() { Store::get_forkchoice_store(backend, state, anchor_block) } else { - // Genesis path (existing code) let genesis_state = State::from_genesis(genesis_config.genesis_time, validators); Store::from_anchor_state(backend, genesis_state) }; From 57ed24c1469bb654f2cf851accd74bc9dacc5b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:11:13 -0300 Subject: [PATCH 07/13] refactor: move big if to helper --- bin/ethlambda/src/checkpoint_sync.rs | 14 ---- bin/ethlambda/src/main.rs | 96 +++++++++++++++++----------- 2 files changed, 59 insertions(+), 51 deletions(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index f25c4d5..c439b95 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -410,18 +410,4 @@ mod tests { state.latest_justified.root = H256::from([99u8; 32]); // Wrong root assert!(verify_checkpoint_state(&state, 1000, &validators).is_err()); } - - #[test] - fn construct_anchor_block_copies_header_fields() { - let validators = vec![create_test_validator()]; - let state = create_test_state(100, validators, 1000); - let block = construct_anchor_block(&state); - assert_eq!(block.slot, state.latest_block_header.slot); - assert_eq!(block.parent_root, state.latest_block_header.parent_root); - assert_eq!( - block.proposer_index, - state.latest_block_header.proposer_index - ); - assert_eq!(block.state_root, state.latest_block_header.state_root); - } } diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 2974be8..ce889bd 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -21,7 +21,7 @@ use tracing::{error, info}; use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt}; use ethlambda_blockchain::BlockChain; -use ethlambda_storage::{Store, backend::RocksDBBackend}; +use ethlambda_storage::{StorageBackend, Store, backend::RocksDBBackend}; const ASCII_ART: &str = r#" _ _ _ _ _ @@ -100,47 +100,19 @@ async fn main() { populate_name_registry(&validator_config); let bootnodes = read_bootnodes(&bootnodes_path); - let validators = genesis_config.validators(); let validator_keys = read_validator_keys(&validators_path, &validator_keys_dir, &options.node_id); let backend = Arc::new(RocksDBBackend::open("./data").expect("Failed to open RocksDB")); - let store = if let Some(checkpoint_url) = &options.checkpoint_sync_url { - // Checkpoint sync path - info!(%checkpoint_url, "Starting checkpoint sync"); - - let Ok(state) = checkpoint_sync::fetch_checkpoint_state(checkpoint_url) - .await - .inspect_err(|err| error!(%checkpoint_url, %err, "Checkpoint sync failed")) - else { - std::process::exit(1); - }; - - let genesis_time = genesis.config.genesis_time; - - // Verify against local genesis config - if let Err(err) = - checkpoint_sync::verify_checkpoint_state(&state, genesis_time, &validators) - { - error!(%err, "Checkpoint state verification failed"); - std::process::exit(1); - } - - let anchor_block = checkpoint_sync::construct_anchor_block(&state); - - info!( - slot = state.slot, - validators = state.validators.len(), - finalized_slot = state.latest_finalized.slot, - "Checkpoint sync complete" - ); - - Store::get_forkchoice_store(backend, state, anchor_block) - } else { - let genesis_state = State::from_genesis(genesis_config.genesis_time, validators); - Store::from_anchor_state(backend, genesis_state) - }; + let store = fetch_initial_state( + options.checkpoint_sync_url.as_deref(), + &genesis_config, + backend.clone(), + ) + .await + .inspect_err(|err| error!(%err, "Failed to initialize state")) + .unwrap_or_else(|_| std::process::exit(1)); let (p2p_tx, p2p_rx) = tokio::sync::mpsc::unbounded_channel(); let blockchain = BlockChain::spawn(store.clone(), p2p_tx, validator_keys); @@ -296,3 +268,53 @@ fn read_hex_file_bytes(path: impl AsRef) -> Vec { }; bytes } + +/// Fetch the initial state for the node. +/// +/// If `checkpoint_url` is provided, performs checkpoint sync by downloading +/// and verifying the finalized state from a remote peer. Otherwise, creates +/// a genesis state from the local genesis configuration. +/// +/// # Arguments +/// +/// * `checkpoint_url` - Optional URL to fetch checkpoint state from +/// * `genesis` - Genesis configuration (for genesis_time verification and genesis state creation) +/// * `validators` - Validator set (moved for genesis state creation) +/// * `backend` - Storage backend for Store creation +/// +/// # Returns +/// +/// `Ok(Store)` on success, or `Err(CheckpointSyncError)` if checkpoint sync fails. +/// Genesis path is infallible and always returns `Ok`. +async fn fetch_initial_state( + checkpoint_url: Option<&str>, + genesis: &GenesisConfig, + backend: Arc, +) -> Result { + let validators = genesis.validators(); + let store = if let Some(checkpoint_url) = checkpoint_url { + // Checkpoint sync path + info!(%checkpoint_url, "Starting checkpoint sync"); + + let state = checkpoint_sync::fetch_checkpoint_state(checkpoint_url).await?; + + // Verify against local genesis config + checkpoint_sync::verify_checkpoint_state(&state, genesis.genesis_time, &validators)?; + + let anchor_block = checkpoint_sync::construct_anchor_block(&state); + + info!( + slot = state.slot, + validators = state.validators.len(), + finalized_slot = state.latest_finalized.slot, + "Checkpoint sync complete" + ); + + Store::get_forkchoice_store(backend, state, anchor_block) + } else { + let genesis_state = State::from_genesis(genesis.genesis_time, validators); + Store::from_anchor_state(backend, genesis_state) + }; + + Ok(store) +} From f559006f233bc804025c3f63bae45798334f2ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:44:32 -0300 Subject: [PATCH 08/13] refactor: use error types instead of strings --- bin/ethlambda/src/checkpoint_sync.rs | 100 +++++++++++++++------------ 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index c439b95..fb6eeb8 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -1,7 +1,7 @@ use std::time::Duration; use ethlambda_types::block::{Block, BlockBody}; -use ethlambda_types::primitives::ssz::{Decode, TreeHash}; +use ethlambda_types::primitives::ssz::{Decode, DecodeError, TreeHash}; use ethlambda_types::state::{State, Validator}; use reqwest::Client; @@ -17,10 +17,38 @@ const CHECKPOINT_READ_TIMEOUT: Duration = Duration::from_secs(15); pub enum CheckpointSyncError { #[error("HTTP request failed: {0}")] Http(#[from] reqwest::Error), - #[error("SSZ deserialization failed: {0}")] - Ssz(String), - #[error("Verification failed: {0}")] - Verification(String), + #[error("SSZ deserialization failed: {0:?}")] + SszDecode(DecodeError), + #[error("checkpoint state slot cannot be 0")] + SlotIsZero, + #[error("checkpoint state has no validators")] + NoValidators, + #[error("genesis time mismatch: expected {expected}, got {got}")] + GenesisTimeMismatch { expected: u64, got: u64 }, + #[error("validator count mismatch: expected {expected}, got {got}")] + ValidatorCountMismatch { expected: usize, got: usize }, + #[error( + "validator at position {position} has non-sequential index (expected {expected}, got {got})" + )] + NonSequentialValidatorIndex { + position: usize, + expected: u64, + got: u64, + }, + #[error("validator {index} pubkey mismatch")] + ValidatorPubkeyMismatch { index: usize }, + #[error("finalized slot cannot exceed state slot")] + FinalizedExceedsStateSlot, + #[error("justified slot cannot precede finalized slot")] + JustifiedPrecedesFinalized, + #[error("justified and finalized at same slot must have matching roots")] + JustifiedFinalizedRootMismatch, + #[error("block header slot exceeds state slot")] + BlockHeaderSlotExceedsState, + #[error("block header at finalized slot must match finalized root")] + BlockHeaderFinalizedRootMismatch, + #[error("block header at justified slot must match justified root")] + BlockHeaderJustifiedRootMismatch, } /// Fetch finalized state from checkpoint sync URL. @@ -53,7 +81,8 @@ pub async fn fetch_checkpoint_state(base_url: &str) -> Result Result<(), CheckpointSyncError> { // Slot sanity check if state.slot == 0 { - return Err(CheckpointSyncError::Verification("slot cannot be 0".into())); + return Err(CheckpointSyncError::SlotIsZero); } // Validators exist if state.validators.is_empty() { - return Err(CheckpointSyncError::Verification("no validators".into())); + return Err(CheckpointSyncError::NoValidators); } // Genesis time matches if state.config.genesis_time != expected_genesis_time { - return Err(CheckpointSyncError::Verification(format!( - "genesis time mismatch: expected {}, got {}", - expected_genesis_time, state.config.genesis_time - ))); + return Err(CheckpointSyncError::GenesisTimeMismatch { + expected: expected_genesis_time, + got: state.config.genesis_time, + }); } // Validator count matches if state.validators.len() != expected_validators.len() { - return Err(CheckpointSyncError::Verification(format!( - "validator count mismatch: expected {}, got {}", - expected_validators.len(), - state.validators.len() - ))); + return Err(CheckpointSyncError::ValidatorCountMismatch { + expected: expected_validators.len(), + got: state.validators.len(), + }); } // Validator indices are sequential (0, 1, 2, ...) for (position, validator) in state.validators.iter().enumerate() { if validator.index != position as u64 { - return Err(CheckpointSyncError::Verification(format!( - "validator at position {} has index {} (expected {})", - position, validator.index, position - ))); + return Err(CheckpointSyncError::NonSequentialValidatorIndex { + position, + expected: position as u64, + got: validator.index, + }); } } @@ -112,41 +141,30 @@ pub fn verify_checkpoint_state( .enumerate() { if state_val.pubkey != expected_val.pubkey { - return Err(CheckpointSyncError::Verification(format!( - "validator {} pubkey mismatch", - i - ))); + return Err(CheckpointSyncError::ValidatorPubkeyMismatch { index: i }); } } // Finalized slot sanity if state.latest_finalized.slot > state.slot { - return Err(CheckpointSyncError::Verification( - "finalized slot cannot exceed state slot".into(), - )); + return Err(CheckpointSyncError::FinalizedExceedsStateSlot); } // Justified must be at or after finalized if state.latest_justified.slot < state.latest_finalized.slot { - return Err(CheckpointSyncError::Verification( - "justified slot cannot precede finalized slot".into(), - )); + return Err(CheckpointSyncError::JustifiedPrecedesFinalized); } // If justified and finalized are at same slot, roots must match if state.latest_justified.slot == state.latest_finalized.slot && state.latest_justified.root != state.latest_finalized.root { - return Err(CheckpointSyncError::Verification( - "justified and finalized at same slot must have matching roots".into(), - )); + return Err(CheckpointSyncError::JustifiedFinalizedRootMismatch); } // Block header slot consistency if state.latest_block_header.slot > state.slot { - return Err(CheckpointSyncError::Verification( - "block header slot exceeds state slot".into(), - )); + return Err(CheckpointSyncError::BlockHeaderSlotExceedsState); } // If block header matches checkpoint slots, roots must match @@ -155,17 +173,13 @@ pub fn verify_checkpoint_state( if state.latest_block_header.slot == state.latest_finalized.slot && block_root != state.latest_finalized.root.0 { - return Err(CheckpointSyncError::Verification( - "block header at finalized slot must match finalized root".into(), - )); + return Err(CheckpointSyncError::BlockHeaderFinalizedRootMismatch); } if state.latest_block_header.slot == state.latest_justified.slot && block_root != state.latest_justified.root.0 { - return Err(CheckpointSyncError::Verification( - "block header at justified slot must match justified root".into(), - )); + return Err(CheckpointSyncError::BlockHeaderJustifiedRootMismatch); } Ok(()) From 8a27971586857d7c246e567b0099d99036cac30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:49:08 -0300 Subject: [PATCH 09/13] docs: update lean clients list --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 86150e5..ebd79de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -319,3 +319,6 @@ cargo test -p ethlambda-blockchain --features skip-signature-verification --test - zeam (Zig): - ream (Rust): - qlean (C++): +- grandine (Rust): +- gean (Go): +- Lantern (C): From 784dd8f9b5da4aa3434a51102231e084680f58d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:44:51 -0300 Subject: [PATCH 10/13] refactor: remove construct_anchor_block --- bin/ethlambda/src/checkpoint_sync.rs | 17 ----------------- bin/ethlambda/src/main.rs | 5 ++--- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index fb6eeb8..730b45c 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -1,6 +1,5 @@ use std::time::Duration; -use ethlambda_types::block::{Block, BlockBody}; use ethlambda_types::primitives::ssz::{Decode, DecodeError, TreeHash}; use ethlambda_types::state::{State, Validator}; use reqwest::Client; @@ -185,22 +184,6 @@ pub fn verify_checkpoint_state( Ok(()) } -/// Construct anchor block from checkpoint state. -/// -/// IMPORTANT: This creates a block with default body. The block's tree_hash_root() -/// will only match the original block if the original also had an empty body. -/// For most checkpoint states, this is acceptable because fork choice uses the -/// anchor checkpoint, not individual block lookups. -pub fn construct_anchor_block(state: &State) -> Block { - Block { - slot: state.latest_block_header.slot, - parent_root: state.latest_block_header.parent_root, - proposer_index: state.latest_block_header.proposer_index, - state_root: state.latest_block_header.state_root, - body: BlockBody::default(), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index ce889bd..3efa948 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -301,8 +301,6 @@ async fn fetch_initial_state( // Verify against local genesis config checkpoint_sync::verify_checkpoint_state(&state, genesis.genesis_time, &validators)?; - let anchor_block = checkpoint_sync::construct_anchor_block(&state); - info!( slot = state.slot, validators = state.validators.len(), @@ -310,7 +308,8 @@ async fn fetch_initial_state( "Checkpoint sync complete" ); - Store::get_forkchoice_store(backend, state, anchor_block) + // Store the anchor state and header, without body + Store::from_anchor_state(backend, state) } else { let genesis_state = State::from_genesis(genesis.genesis_time, validators); Store::from_anchor_state(backend, genesis_state) From deab2f6a804bc760e57b854bd9e30e866d155f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:01:12 -0300 Subject: [PATCH 11/13] refactor: use early-return in fetch_initial_state Co-authored-by: Pablo Deymonnaz --- bin/ethlambda/src/main.rs | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 3efa948..d116c58 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -292,28 +292,28 @@ async fn fetch_initial_state( backend: Arc, ) -> Result { let validators = genesis.validators(); - let store = if let Some(checkpoint_url) = checkpoint_url { - // Checkpoint sync path - info!(%checkpoint_url, "Starting checkpoint sync"); - let state = checkpoint_sync::fetch_checkpoint_state(checkpoint_url).await?; + let Some(checkpoint_url) = checkpoint_url else { + info!("No checkpoint sync URL provided, initializing from genesis state"); + let genesis_state = State::from_genesis(genesis.genesis_time, validators); + return Ok(Store::from_anchor_state(backend, genesis_state)); + }; - // Verify against local genesis config - checkpoint_sync::verify_checkpoint_state(&state, genesis.genesis_time, &validators)?; + // Checkpoint sync path + info!(%checkpoint_url, "Starting checkpoint sync"); - info!( - slot = state.slot, - validators = state.validators.len(), - finalized_slot = state.latest_finalized.slot, - "Checkpoint sync complete" - ); + let state = checkpoint_sync::fetch_checkpoint_state(checkpoint_url).await?; - // Store the anchor state and header, without body - Store::from_anchor_state(backend, state) - } else { - let genesis_state = State::from_genesis(genesis.genesis_time, validators); - Store::from_anchor_state(backend, genesis_state) - }; + // Verify against local genesis config + checkpoint_sync::verify_checkpoint_state(&state, genesis.genesis_time, &validators)?; + + info!( + slot = state.slot, + validators = state.validators.len(), + finalized_slot = state.latest_finalized.slot, + "Checkpoint sync complete" + ); - Ok(store) + // Store the anchor state and header, without body + Ok(Store::from_anchor_state(backend, state)) } From e1414926635030448b8574645a3d0ca8b852a8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:12:30 -0300 Subject: [PATCH 12/13] refactor: return error from main functiona --- Cargo.lock | 17 +++++++++++++++++ Cargo.toml | 1 + bin/ethlambda/Cargo.toml | 1 + bin/ethlambda/src/main.rs | 7 ++++--- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f2dc77..ff2283d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,6 +1945,7 @@ dependencies = [ "ethlambda-rpc", "ethlambda-storage", "ethlambda-types", + "eyre", "hex", "reqwest", "serde", @@ -2356,6 +2357,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fancy-regex" version = "0.14.0" @@ -3208,6 +3219,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.12.1" diff --git a/Cargo.toml b/Cargo.toml index 92c8100..33ed1ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,3 +71,4 @@ vergen-git2 = { version = "9", features = ["rustc"] } rand = "0.9" rocksdb = "0.24" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +eyre = "0.6" diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index 3601d73..7a89768 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -26,6 +26,7 @@ hex.workspace = true clap.workspace = true reqwest.workspace = true thiserror.workspace = true +eyre.workspace = true [build-dependencies] vergen-git2.workspace = true diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index d116c58..d3cbb35 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -54,7 +54,7 @@ struct CliOptions { } #[tokio::main] -async fn main() { +async fn main() -> eyre::Result<()> { let filter = EnvFilter::builder() .with_default_directive(tracing::Level::INFO.into()) .from_env_lossy(); @@ -111,8 +111,7 @@ async fn main() { backend.clone(), ) .await - .inspect_err(|err| error!(%err, "Failed to initialize state")) - .unwrap_or_else(|_| std::process::exit(1)); + .inspect_err(|err| error!(%err, "Failed to initialize state"))?; let (p2p_tx, p2p_rx) = tokio::sync::mpsc::unbounded_channel(); let blockchain = BlockChain::spawn(store.clone(), p2p_tx, validator_keys); @@ -141,6 +140,8 @@ async fn main() { } } println!("Shutting down..."); + + Ok(()) } fn populate_name_registry(validator_config: impl AsRef) { From 91ec389d20fe6c4f450ea31cac615e04b1ae0172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:15:41 -0300 Subject: [PATCH 13/13] refactor: merge fetch and verify functions --- bin/ethlambda/src/checkpoint_sync.rs | 11 +++++++++-- bin/ethlambda/src/main.rs | 7 +++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 730b45c..8b163ad 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -62,7 +62,11 @@ pub enum CheckpointSyncError { /// failing fast if the connection stalls. A plain total timeout would /// disconnect even for valid downloads if the state is simply too large to /// transfer within the time limit. -pub async fn fetch_checkpoint_state(base_url: &str) -> Result { +pub async fn fetch_checkpoint_state( + base_url: &str, + expected_genesis_time: u64, + expected_validators: &[Validator], +) -> Result { let base_url = base_url.trim_end_matches('/'); let url = format!("{base_url}/lean/v0/states/finalized"); // Use .read_timeout() to detect stalled downloads (inactivity timer). @@ -81,6 +85,9 @@ pub async fn fetch_checkpoint_state(base_url: &str) -> Result Result