Skip to content

Commit d598499

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/proposer-signature-outside-block-proof
# Conflicts: # crates/blockchain/src/store.rs
2 parents adbc459 + d565adc commit d598499

51 files changed

Lines changed: 8109 additions & 216 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@ jobs:
3030
components: rustfmt, clippy
3131

3232
- name: Setup cache
33+
# Tools under tooling/ are separate Cargo workspaces with their own
34+
# target dir and Cargo.lock, so they need listing explicitly or their
35+
# builds are neither cached nor reflected in the cache key.
3336
uses: Swatinem/rust-cache@v2
37+
with:
38+
workspaces: |
39+
.
40+
tooling/event-monitor
3441
3542
- name: Check formatting
3643
run: cargo fmt --all -- --check
@@ -41,6 +48,24 @@ jobs:
4148
- name: Clippy
4249
run: cargo clippy --workspace --all-targets -- -D warnings
4350

51+
# tooling/event-monitor declares its own [workspace] table, so every step
52+
# above stops at the root workspace members and never reaches it. Its
53+
# tests run in this job rather than in `test` because clippy has already
54+
# compiled the test targets, and because they need none of that job's
55+
# leanSpec fixtures.
56+
# `--locked` so the committed Cargo.lock is actually enforced: without it
57+
# cargo silently resolves and rewrites the lockfile in CI, and a stale or
58+
# missing entry never fails the build.
59+
- name: Lint tooling
60+
working-directory: tooling/event-monitor
61+
run: |
62+
cargo fmt --all -- --check
63+
cargo clippy --locked --all-targets -- -D warnings
64+
65+
- name: Test tooling
66+
working-directory: tooling/event-monitor
67+
run: cargo test --locked
68+
4469
test:
4570
name: Test
4671
runs-on: ubuntu-latest

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@ actual_slot = finalized_slot + 1 + relative_index
288288

289289
## HTTP Servers (API + Metrics)
290290

291-
The RPC crate runs two independent Axum servers (API on `:5052`, metrics/debug on `:5054`). See [`docs/rpc.md`](docs/rpc.md) for the full reference: CLI flags and defaults, the API endpoints (health, finalized state/block, justified checkpoint, blocks by root/slot, fork-choice tree + D3.js UI, runtime aggregator toggle), the metrics/debug endpoints (Prometheus `/metrics`, jemalloc heap profiling), the Hive test-driver endpoints, plus request/response shapes, status codes, and content types.
291+
The RPC crate serves the API router (`--api-port`, default 5052) and the metrics/debug routers
292+
(`--metrics-port`, default 5054). When the two ports differ it binds two independent Axum servers;
293+
when they are equal it merges all three routers onto a single listener, so pointing both flags at
294+
one port is supported and not a misconfiguration. See [`docs/rpc.md`](docs/rpc.md) for the full reference: CLI flags and defaults, the API endpoints (health, finalized state/block, justified checkpoint, blocks by root/slot, fork-choice tree + D3.js UI, runtime aggregator toggle), the metrics/debug endpoints (Prometheus `/metrics`, jemalloc heap profiling), the Hive test-driver endpoints, plus request/response shapes, status codes, and content types.
292295

293296
## Configuration Files
294297

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/ethlambda/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,11 @@ tikv-jemallocator = { workspace = true, optional = true }
4949

5050
libc.workspace = true
5151

52+
[dev-dependencies]
53+
# `test-util` for `#[tokio::test(start_paused = true)]`: the checkpoint-sync
54+
# tests would otherwise wait out the real retry backoff. Dev-only, so the
55+
# feature never reaches the shipped binary.
56+
tokio = { workspace = true, features = ["test-util"] }
57+
5258
[build-dependencies]
5359
vergen-git2.workspace = true

bin/ethlambda/src/cli.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,17 @@ pub(crate) struct CliOptions {
3737
#[arg(long)]
3838
pub(crate) node_id: String,
3939
/// Base URL(s) of checkpoint-sync peer API servers (e.g., http://peer:5052).
40-
/// When set, skips genesis initialization and fetches the finalized state
41-
/// and block from each peer's `/lean/v0/states/finalized` and
42-
/// `/lean/v0/blocks/finalized` endpoints. For backward compatibility, a
43-
/// URL ending in `/lean/v0/states/finalized` is accepted and the trailing
44-
/// path is stripped.
40+
/// When set, fetches the finalized state and block from each peer's
41+
/// `/lean/v0/states/finalized` and `/lean/v0/blocks/finalized` endpoints.
42+
/// For backward compatibility, a URL ending in
43+
/// `/lean/v0/states/finalized` is accepted and the trailing path is
44+
/// stripped.
45+
///
46+
/// This is a fallback, not a precedence: state already in the data
47+
/// directory always wins, so these URLs are only used when there is no
48+
/// resumable state on disk (or it has fallen too far behind the current
49+
/// slot). With neither resumable state nor URLs, the node starts from
50+
/// genesis.
4551
///
4652
/// Multiple URLs may be supplied for redundancy, either comma-separated
4753
/// (`--checkpoint-sync-url u1,u2`) or by repeating the flag

bin/ethlambda/src/main.rs

Lines changed: 199 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -641,11 +641,26 @@ fn read_hex_file_bytes(path: impl AsRef<Path>) -> eyre::Result<Vec<u8>> {
641641

642642
/// Fetch the initial state for the node.
643643
///
644-
/// If `checkpoint_urls` is empty, creates a genesis state from the local
645-
/// genesis configuration. Otherwise performs checkpoint sync by downloading
646-
/// and verifying the finalized state AND signed block from a peer. URLs are
647-
/// tried in order: the first peer that succeeds wins, and failures fall over
648-
/// to the next URL. Startup only aborts if every URL fails.
644+
/// State already on disk wins: a previous run's DB is resumed from whenever it
645+
/// exists and belongs to this network, whether or not `checkpoint_urls` is
646+
/// supplied. `checkpoint_urls` is the fallback for when there is nothing
647+
/// resumable on disk, or when what is there has fallen too far behind the
648+
/// current slot to be worth catching up over P2P
649+
/// ([`MAX_RESUMABLE_DB_STATE_AGE`]).
650+
///
651+
/// With no resumable DB state, a non-empty `checkpoint_urls` performs checkpoint
652+
/// sync by downloading and verifying the finalized state AND signed block from a
653+
/// peer. URLs are tried in order: the first peer that succeeds wins, and
654+
/// failures fall over to the next URL. Startup only aborts if every URL fails.
655+
/// An empty `checkpoint_urls` creates a genesis state from the local genesis
656+
/// configuration.
657+
///
658+
/// Aborting when every URL fails is deliberate, and applies even when a stale
659+
/// resumable DB is in hand: an operator who configured a checkpoint URL asked
660+
/// for a specific anchor, so an unreachable one is a misconfiguration to
661+
/// surface at boot rather than paper over by silently starting a node that is
662+
/// hours behind. Dropping the flag is the way to say "resume whatever is on
663+
/// disk"; that path never aborts.
649664
///
650665
/// Fetching the matching signed block lets the local store serve a valid
651666
/// anchor via the `BlocksByRoot` req-resp protocol; without it, peers
@@ -669,20 +684,10 @@ async fn fetch_initial_state(
669684
) -> Result<Store, checkpoint_sync::CheckpointSyncError> {
670685
let validators = genesis.validators();
671686

672-
if checkpoint_urls.is_empty() {
673-
info!("No checkpoint sync URL provided, initializing from genesis state");
674-
let genesis_state = State::from_genesis(genesis.genesis_time, validators);
675-
return Ok(Store::from_anchor_state(backend, genesis_state));
676-
};
677-
678-
// Checkpoint sync path: try URLs in order, fail over to the next on error.
679-
info!(
680-
url_count = checkpoint_urls.len(),
681-
"Starting checkpoint sync"
682-
);
683-
// Checkpoint sync path
684-
685-
// Prefer resuming from a fresh on-disk state to avoid re-downloading what we already have.
687+
// Prefer resuming from on-disk state to avoid re-downloading what we already
688+
// have. Tried before the checkpoint-sync and genesis paths so that a restart
689+
// without `--checkpoint-sync-url` keeps the chain instead of writing a
690+
// slot-0 anchor over it.
686691
if let Ok(Some(store)) = Store::from_db_state(backend.clone(), genesis.genesis_time) {
687692
let now_ms = SystemTime::UNIX_EPOCH
688693
.elapsed()
@@ -693,18 +698,30 @@ async fn fetch_initial_state(
693698
let head_slot = store.head_slot();
694699
let gap = current_slot.saturating_sub(head_slot);
695700
if gap <= MAX_RESUMABLE_DB_STATE_AGE {
696-
info!(
697-
head_slot,
698-
current_slot, gap, "Resuming from existing DB state"
699-
);
701+
info!(head_slot, current_slot, gap, "Resuming from existing DB");
700702
return Ok(store);
701703
}
702-
warn!(
703-
head_slot,
704-
current_slot, gap, "Existing DB state is stale; falling through to checkpoint sync"
705-
);
704+
// No checkpoint URL was configured, so just run the node against the
705+
// data directory it was given: that is the setup asked for, and there
706+
// is no anchor to switch to. The warning is the point of this arm,
707+
// since the DB is known to be stale and range sync may not be able to
708+
// close a gap this large: peers prune block signatures past
709+
// `SIGNATURE_PRUNING_RANGE`, so beyond that horizon they cannot serve
710+
// the history the node is missing.
711+
if checkpoint_urls.is_empty() {
712+
warn!(head_slot, current_slot, gap, "DB is stale; resuming anyway");
713+
return Ok(store);
714+
}
715+
warn!(head_slot, current_slot, gap, "DB is stale; checkpoint sync");
716+
}
717+
718+
if checkpoint_urls.is_empty() {
719+
info!("No checkpoint sync URL provided, initializing from genesis state");
720+
let genesis_state = State::from_genesis(genesis.genesis_time, validators);
721+
return Ok(Store::from_anchor_state(backend, genesis_state));
706722
}
707723

724+
// Checkpoint sync path: try URLs in order, fail over to the next on error.
708725
info!(?checkpoint_urls, "Starting checkpoint sync");
709726

710727
let (state, signed_block) = checkpoint_sync::fetch_anchor_with_retry(
@@ -740,6 +757,8 @@ async fn fetch_initial_state(
740757
#[cfg(test)]
741758
mod tests {
742759
use super::*;
760+
use ethlambda_storage::backend::InMemoryBackend;
761+
use ethlambda_types::genesis::GenesisValidatorEntry;
743762

744763
/// Validator-config snippet matching `lean-quickstart`'s ansible-devnet
745764
/// where networks share a non-default committee count.
@@ -828,4 +847,157 @@ validators:
828847
.unwrap_or(1);
829848
assert_eq!(resolved, 1);
830849
}
850+
851+
/// Slot of the anchor seeded into the test DB. Any non-zero slot works: a
852+
/// genesis re-initialization always anchors at slot 0, so a non-zero head
853+
/// slot is what distinguishes "resumed from disk" from "started over".
854+
const SEEDED_HEAD_SLOT: u64 = 12;
855+
856+
/// Loopback port 1 refuses connections immediately, so the checkpoint-sync
857+
/// path fails fast and deterministically without reaching the network.
858+
const UNREACHABLE_CHECKPOINT_URL: &str = "http://127.0.0.1:1";
859+
860+
fn now_secs() -> u64 {
861+
SystemTime::UNIX_EPOCH
862+
.elapsed()
863+
.expect("already past the unix epoch")
864+
.as_secs()
865+
}
866+
867+
/// A `genesis_time` placing the current slot exactly `gap` slots ahead of
868+
/// [`SEEDED_HEAD_SLOT`], so a test picks which side of
869+
/// [`MAX_RESUMABLE_DB_STATE_AGE`] the seeded DB lands on.
870+
///
871+
/// `current_slot` is derived from the wall clock inside
872+
/// [`fetch_initial_state`], so `genesis_time` is the only knob and no clock
873+
/// injection is needed. Sub-second truncation here only ever *shortens* the
874+
/// elapsed time, and a whole slot of it would have to pass between this
875+
/// call and the read inside the function to shift the gap.
876+
fn genesis_time_for_gap(gap: u64) -> u64 {
877+
let seconds_per_slot = MILLISECONDS_PER_SLOT / 1_000;
878+
now_secs() - (SEEDED_HEAD_SLOT + gap) * seconds_per_slot
879+
}
880+
881+
/// Single-validator genesis config. The pubkeys are placeholders; none of
882+
/// the paths under test verify signatures.
883+
fn test_genesis(genesis_time: u64) -> GenesisConfig {
884+
GenesisConfig {
885+
genesis_time,
886+
genesis_validators: vec![GenesisValidatorEntry {
887+
attestation_pubkey: [1u8; 52],
888+
proposal_pubkey: [2u8; 52],
889+
}],
890+
}
891+
}
892+
893+
/// Write an anchor at [`SEEDED_HEAD_SLOT`] into `backend`, standing in for a
894+
/// previous run's persisted chain state.
895+
fn seed_db(backend: Arc<dyn StorageBackend>, genesis: &GenesisConfig) {
896+
let mut anchor = State::from_genesis(genesis.genesis_time, genesis.validators());
897+
anchor.slot = SEEDED_HEAD_SLOT;
898+
anchor.latest_block_header.slot = SEEDED_HEAD_SLOT;
899+
Store::from_anchor_state(backend, anchor);
900+
}
901+
902+
#[tokio::test]
903+
async fn initializes_from_genesis_when_db_is_empty() {
904+
let genesis = test_genesis(now_secs());
905+
let backend = Arc::new(InMemoryBackend::default());
906+
907+
let store = fetch_initial_state(&[], &genesis, backend).await.unwrap();
908+
909+
assert_eq!(store.head_slot(), 0);
910+
}
911+
912+
#[tokio::test]
913+
async fn resumes_from_fresh_db_without_checkpoint_url() {
914+
let genesis = test_genesis(genesis_time_for_gap(MAX_RESUMABLE_DB_STATE_AGE / 2));
915+
let backend = Arc::new(InMemoryBackend::default());
916+
seed_db(backend.clone(), &genesis);
917+
918+
let store = fetch_initial_state(&[], &genesis, backend).await.unwrap();
919+
920+
assert_eq!(store.head_slot(), SEEDED_HEAD_SLOT);
921+
}
922+
923+
/// With no checkpoint URL to fall back to, resuming a stale DB beats
924+
/// clobbering it with a slot-0 genesis anchor: P2P forward-sync can close
925+
/// the gap, a genesis re-init cannot.
926+
#[tokio::test]
927+
async fn resumes_from_stale_db_without_checkpoint_url() {
928+
let genesis = test_genesis(genesis_time_for_gap(MAX_RESUMABLE_DB_STATE_AGE + 100));
929+
let backend = Arc::new(InMemoryBackend::default());
930+
seed_db(backend.clone(), &genesis);
931+
932+
let store = fetch_initial_state(&[], &genesis, backend).await.unwrap();
933+
934+
assert_eq!(store.head_slot(), SEEDED_HEAD_SLOT);
935+
}
936+
937+
/// A DB inside the resume window wins over a checkpoint URL: the store
938+
/// comes back even though the URL is unreachable, so nothing was dialed.
939+
///
940+
/// This and [`falls_through_to_checkpoint_sync_when_db_is_stale`] are what
941+
/// pin the [`MAX_RESUMABLE_DB_STATE_AGE`] comparison. The no-URL tests
942+
/// cannot: both of their branches return the same store, so inverting the
943+
/// threshold leaves them green. The gap is exactly the window bound here,
944+
/// so an off-by-one to `<` also fails this test.
945+
#[tokio::test(start_paused = true)]
946+
async fn resumes_from_fresh_db_with_checkpoint_url() {
947+
let genesis = test_genesis(genesis_time_for_gap(MAX_RESUMABLE_DB_STATE_AGE));
948+
let backend = Arc::new(InMemoryBackend::default());
949+
seed_db(backend.clone(), &genesis);
950+
951+
let urls = [UNREACHABLE_CHECKPOINT_URL.to_string()];
952+
let store = fetch_initial_state(&urls, &genesis, backend).await.unwrap();
953+
954+
assert_eq!(store.head_slot(), SEEDED_HEAD_SLOT);
955+
}
956+
957+
/// Past the resume window a checkpoint URL takes over, so an unreachable
958+
/// one surfaces as a startup error rather than a silent stale resume.
959+
///
960+
/// Paused time collapses the `CHECKPOINT_RETRY_BACKOFF` sleeps between
961+
/// attempts; the connection refusal itself is immediate.
962+
#[tokio::test(start_paused = true)]
963+
async fn falls_through_to_checkpoint_sync_when_db_is_stale() {
964+
let genesis = test_genesis(genesis_time_for_gap(MAX_RESUMABLE_DB_STATE_AGE + 1));
965+
let backend = Arc::new(InMemoryBackend::default());
966+
seed_db(backend.clone(), &genesis);
967+
968+
let urls = [UNREACHABLE_CHECKPOINT_URL.to_string()];
969+
// `Store` is not `Debug`, so unwrap the error by pattern rather than
970+
// with `expect_err`.
971+
let Err(err) = fetch_initial_state(&urls, &genesis, backend).await else {
972+
panic!("unreachable checkpoint URL must abort startup");
973+
};
974+
975+
assert!(
976+
matches!(err, checkpoint_sync::CheckpointSyncError::Http(_)),
977+
"expected a transport error, got {err:?}"
978+
);
979+
}
980+
981+
/// A DB from another network is not resumable, so the no-URL path still
982+
/// falls back to genesis.
983+
///
984+
/// This pins current behavior, not a desired one. `from_db_state` treats a
985+
/// `GENESIS_TIME` mismatch as an empty DB and only warns, so with no
986+
/// checkpoint URL the node writes a genesis anchor over a populated
987+
/// foreign-network directory: the same data loss the resume ordering
988+
/// removes everywhere else. Left as-is deliberately, and this test is here
989+
/// to make the change visible when someone fixes it.
990+
#[tokio::test]
991+
async fn initializes_from_genesis_when_db_genesis_time_differs() {
992+
let seeded_genesis = test_genesis(now_secs());
993+
let backend = Arc::new(InMemoryBackend::default());
994+
seed_db(backend.clone(), &seeded_genesis);
995+
996+
let other_genesis = test_genesis(seeded_genesis.genesis_time + 1);
997+
let store = fetch_initial_state(&[], &other_genesis, backend)
998+
.await
999+
.unwrap();
1000+
1001+
assert_eq!(store.head_slot(), 0);
1002+
}
8311003
}

0 commit comments

Comments
 (0)