From 89219b03c13738bfb8813fc4d49958ed9832a92a 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, 24 Jun 2026 10:51:15 -0300 Subject: [PATCH 1/9] feat(rpc): add GET /lean/v0/node/{identity,syncing} Exposes node identity (build version) and sync status (head_slot, sync_distance, is_syncing) derived from store.time() / INTERVALS_PER_SLOT. --- crates/blockchain/src/lib.rs | 1 + crates/blockchain/src/sync_status.rs | 2 +- crates/net/rpc/src/lib.rs | 2 + crates/net/rpc/src/node.rs | 99 ++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 crates/net/rpc/src/node.rs diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 4360377d..9ed68845 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -49,6 +49,7 @@ pub const INTERVALS_PER_SLOT: u64 = 5; /// Milliseconds in a slot (derived from interval duration and count). pub const MILLISECONDS_PER_SLOT: u64 = MILLISECONDS_PER_INTERVAL * INTERVALS_PER_SLOT; pub use ethlambda_types::block::MAX_ATTESTATIONS_DATA; +pub use sync_status::SYNC_LAG_THRESHOLD; /// Future-slot tolerance for gossip attestations, expressed in intervals. /// /// Bounds the clock skew the time check is willing to absorb when admitting a diff --git a/crates/blockchain/src/sync_status.rs b/crates/blockchain/src/sync_status.rs index 02c71c9f..9130e278 100644 --- a/crates/blockchain/src/sync_status.rs +++ b/crates/blockchain/src/sync_status.rs @@ -5,7 +5,7 @@ use crate::metrics::SyncStatus; /// Local head lag beyond which the node is considered to be syncing. /// /// See: leanSpec PR #708. -const SYNC_LAG_THRESHOLD: u64 = 4; +pub const SYNC_LAG_THRESHOLD: u64 = 4; /// Freshest-known block lag beyond which the network is considered stalled. /// /// During a network-wide stall the node remains synced so validators can help diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 09268765..9df8c810 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -14,6 +14,7 @@ mod blocks; mod fork_choice; mod heap_profiling; pub mod metrics; +mod node; pub mod test_driver; pub(crate) use base::json_response; @@ -100,6 +101,7 @@ fn build_api_router(store: Store) -> Router { .merge(blocks::routes()) .merge(fork_choice::routes()) .merge(admin::routes()) + .merge(node::routes()) .with_state(store) } diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs new file mode 100644 index 00000000..5ef66d07 --- /dev/null +++ b/crates/net/rpc/src/node.rs @@ -0,0 +1,99 @@ +use axum::{Router, extract::State, response::IntoResponse, routing::get}; +use ethlambda_blockchain::{INTERVALS_PER_SLOT, SYNC_LAG_THRESHOLD}; +use ethlambda_storage::Store; +use serde::Serialize; + +use crate::json_response; + +#[derive(Serialize)] +struct SyncingResponse { + is_syncing: bool, + head_slot: u64, + sync_distance: u64, +} + +#[derive(Serialize)] +struct IdentityResponse { + version: &'static str, +} + +/// Simplified sync status: head-vs-wall-clock lag only. Unlike `SyncStatusTracker` +/// it has no hysteresis or stall-override (it is stateless). +async fn get_syncing(State(store): State) -> impl IntoResponse { + let head_slot = store.head_slot(); + // store.time() counts 800ms intervals from genesis; divide to get wall slot. + let wall_slot = store.time() / INTERVALS_PER_SLOT; + let sync_distance = wall_slot.saturating_sub(head_slot); + json_response(SyncingResponse { + is_syncing: sync_distance > SYNC_LAG_THRESHOLD, + head_slot, + sync_distance, + }) +} + +async fn get_identity() -> impl IntoResponse { + json_response(IdentityResponse { + version: env!("CARGO_PKG_VERSION"), + }) +} + +pub(crate) fn routes() -> Router { + Router::new() + .route("/lean/v0/node/syncing", get(get_syncing)) + .route("/lean/v0/node/identity", get(get_identity)) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use ethlambda_storage::{Store, backend::InMemoryBackend}; + use http_body_util::BodyExt; + use std::sync::Arc; + use tower::ServiceExt; + + use crate::test_utils::create_test_state; + + #[tokio::test] + async fn node_syncing_reports_head_slot() { + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let app = crate::build_api_router(store); + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/node/syncing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["head_slot"], 0); + // Fresh store: time=0, head=0 → no lag, not syncing. + assert_eq!(json["sync_distance"], 0); + assert_eq!(json["is_syncing"], false); + } + + #[tokio::test] + async fn node_identity_reports_version() { + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let app = crate::build_api_router(store); + let resp = app + .oneshot( + Request::builder() + .uri("/lean/v0/node/identity") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["version"].is_string()); + } +} From 6d08bcdd56daa7f770a6ab0813518f353ba47b2e 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, 24 Jun 2026 15:38:28 -0300 Subject: [PATCH 2/9] fix(rpc): address review feedback on node endpoints (wall-clock sync, finalized_slot) - Replace store.time()-based wall_slot with real SystemTime::now() so sync_distance is correct after an offline gap (store.time() freezes during downtime, causing false is_syncing=false on restart) - Add finalized_slot field to SyncingResponse from store.latest_finalized() - Split node syncing tests: far-behind case (genesis_time=1000) asserts is_syncing=true; up-to-date case (genesis_time year 2100) asserts is_syncing=false and sync_distance=0 - Add one-line doc comment to SYNC_LAG_THRESHOLD re-export in blockchain lib.rs --- crates/blockchain/src/lib.rs | 1 + crates/net/rpc/src/node.rs | 55 ++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 9ed68845..46f99899 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -49,6 +49,7 @@ pub const INTERVALS_PER_SLOT: u64 = 5; /// Milliseconds in a slot (derived from interval duration and count). pub const MILLISECONDS_PER_SLOT: u64 = MILLISECONDS_PER_INTERVAL * INTERVALS_PER_SLOT; pub use ethlambda_types::block::MAX_ATTESTATIONS_DATA; +/// Slots of head-vs-wall-clock lag above which a node is considered syncing. pub use sync_status::SYNC_LAG_THRESHOLD; /// Future-slot tolerance for gossip attestations, expressed in intervals. /// diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index 5ef66d07..1b0103b6 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -1,5 +1,5 @@ use axum::{Router, extract::State, response::IntoResponse, routing::get}; -use ethlambda_blockchain::{INTERVALS_PER_SLOT, SYNC_LAG_THRESHOLD}; +use ethlambda_blockchain::{MILLISECONDS_PER_SLOT, SYNC_LAG_THRESHOLD}; use ethlambda_storage::Store; use serde::Serialize; @@ -10,6 +10,7 @@ struct SyncingResponse { is_syncing: bool, head_slot: u64, sync_distance: u64, + finalized_slot: u64, } #[derive(Serialize)] @@ -18,16 +19,23 @@ struct IdentityResponse { } /// Simplified sync status: head-vs-wall-clock lag only. Unlike `SyncStatusTracker` -/// it has no hysteresis or stall-override (it is stateless). +/// it has no hysteresis or stall-override (it is stateless). Sync distance is the +/// number of slots between the node's current head and the current wall-clock slot. async fn get_syncing(State(store): State) -> impl IntoResponse { + let genesis_ms = store.config().genesis_time.saturating_mul(1000); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(genesis_ms); + let wall_slot = now_ms.saturating_sub(genesis_ms) / MILLISECONDS_PER_SLOT; let head_slot = store.head_slot(); - // store.time() counts 800ms intervals from genesis; divide to get wall slot. - let wall_slot = store.time() / INTERVALS_PER_SLOT; let sync_distance = wall_slot.saturating_sub(head_slot); + let finalized_slot = store.latest_finalized().slot; json_response(SyncingResponse { is_syncing: sync_distance > SYNC_LAG_THRESHOLD, head_slot, sync_distance, + finalized_slot, }) } @@ -49,16 +57,17 @@ mod tests { body::Body, http::{Request, StatusCode}, }; + use ethlambda_blockchain::SYNC_LAG_THRESHOLD; use ethlambda_storage::{Store, backend::InMemoryBackend}; + use ethlambda_types::state::ChainConfig; use http_body_util::BodyExt; use std::sync::Arc; use tower::ServiceExt; use crate::test_utils::create_test_state; - #[tokio::test] - async fn node_syncing_reports_head_slot() { - let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + /// Helper: GET /lean/v0/node/syncing and parse JSON body. + async fn get_syncing_json(store: Store) -> serde_json::Value { let app = crate::build_api_router(store); let resp = app .oneshot( @@ -71,9 +80,37 @@ mod tests { .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = resp.into_body().collect().await.unwrap().to_bytes(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + serde_json::from_slice(&body).unwrap() + } + + #[tokio::test] + async fn node_syncing_far_behind_wall_clock() { + // create_test_state() has genesis_time=1000 (year 1970), so wall_slot is huge. + // head_slot=0 → sync_distance is large → is_syncing=true. + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let json = get_syncing_json(store).await; + assert_eq!(json["head_slot"], 0); + assert_eq!(json["finalized_slot"], 0); + assert!( + json["sync_distance"].as_u64().unwrap() > SYNC_LAG_THRESHOLD, + "expected large sync_distance, got {}", + json["sync_distance"] + ); + assert_eq!(json["is_syncing"], true); + } + + #[tokio::test] + async fn node_syncing_up_to_date() { + // Set genesis_time to far future so wall_slot=0 and head_slot=0 → not syncing. + let mut state = create_test_state(); + // Unix timestamp ~year 2100 (4102444800 seconds), well beyond any test run. + state.config = ChainConfig { + genesis_time: 4_102_444_800, + }; + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), state); + let json = get_syncing_json(store).await; assert_eq!(json["head_slot"], 0); - // Fresh store: time=0, head=0 → no lag, not syncing. + assert_eq!(json["finalized_slot"], 0); assert_eq!(json["sync_distance"], 0); assert_eq!(json["is_syncing"], false); } From 3db9269d0c49f9fc483fa43c6f4f07b7687b33fb 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, 1 Jul 2026 17:56:31 -0300 Subject: [PATCH 3/9] docs(rpc): document GET /lean/v0/node/{identity,syncing} --- docs/rpc.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/rpc.md b/docs/rpc.md index c5f94dd5..1ad4cddd 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -30,6 +30,8 @@ If `--api-port` and `--metrics-port` are equal, all routers are merged onto a si | `GET` | `/lean/v0/blocks/{block_id}/header` | JSON | Block header by root or slot | | `GET` | `/lean/v0/fork_choice` | JSON | Fork-choice tree with per-block weights | | `GET` | `/lean/v0/fork_choice/ui` | HTML | Interactive D3.js visualization | +| `GET` | `/lean/v0/node/identity` | JSON | Client version | +| `GET` | `/lean/v0/node/syncing` | JSON | Sync status relative to the wall clock | | `GET` | `/lean/v0/admin/aggregator` | JSON | Current aggregator role | | `POST` | `/lean/v0/admin/aggregator` | JSON | Toggle aggregator role at runtime | @@ -91,6 +93,22 @@ The fork-choice tree from the finalized root, with LMD-GHOST weights computed ov `/lean/v0/fork_choice/ui` serves an interactive D3.js page rendering this data. See [Fork Choice Visualization](./fork_choice_visualization.md). +### `GET /lean/v0/node/identity` + +```json +{ "version": "0.1.0" } +``` + +The crate version baked in at compile time (`CARGO_PKG_VERSION`). + +### `GET /lean/v0/node/syncing` + +```json +{ "is_syncing": false, "head_slot": 1024, "sync_distance": 1, "finalized_slot": 986 } +``` + +`sync_distance` is the number of slots between the node's current head and the current wall-clock slot; `is_syncing` is true when it exceeds `SYNC_LAG_THRESHOLD`. This is a stateless per-request snapshot: unlike the internal `SyncStatusTracker` it has no hysteresis or stall override, so it can disagree with the node's own duty gating around the threshold. + ### `GET` / `POST /lean/v0/admin/aggregator` Toggle the aggregator role at runtime without restarting the node (hot-standby model, ported from leanSpec PR #636). From e9bff5ea5f0027e6c7e160c952ba64ecbc5ffbe7 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, 6 Jul 2026 14:32:27 -0300 Subject: [PATCH 4/9] feat(rpc): return full --version string from node/identity The node/identity endpoint returned only the crate semver (CARGO_PKG_VERSION, e.g. "0.1.0"), which is far less useful for health dashboards and cross-client debugging than the full version string the binary prints for --version: semver plus git branch/short-SHA, target triple, and rustc version. That git/rustc build metadata is emitted by the binary's build.rs (vergen-git2) and is only visible to that crate at compile time, so the net/rpc crate can't reconstruct it. Carry the binary's CLIENT_VERSION in RpcConfig and have the node/identity route report it. --- bin/ethlambda/src/main.rs | 1 + crates/net/rpc/src/lib.rs | 45 +++++++++++++++++++++++--------------- crates/net/rpc/src/node.rs | 24 ++++++++++++-------- docs/rpc.md | 6 +++-- 4 files changed, 47 insertions(+), 29 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 914c6c86..b75f9d84 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -89,6 +89,7 @@ async fn main() -> eyre::Result<()> { http_address: options.http_address, api_port: options.api_port, metrics_port: options.metrics_port, + version: version::CLIENT_VERSION, }; println!("{ASCII_ART}"); diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 967eaf0d..5420b64a 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -24,6 +24,13 @@ pub struct RpcConfig { pub http_address: IpAddr, pub api_port: u16, pub metrics_port: u16, + /// Full client version string, as printed by `ethlambda --version`. + /// + /// Served verbatim by `GET /lean/v0/node/identity`. It carries git and + /// rustc build metadata that only the binary crate can produce (via its + /// `build.rs`), so the binary supplies it here rather than the `net/rpc` + /// crate building it itself. + pub version: &'static str, } /// Start the RPC server in Hive test-driver mode. @@ -54,7 +61,7 @@ pub async fn start_rpc_server( aggregator: AggregatorController, shutdown: CancellationToken, ) -> Result<(), std::io::Error> { - let api_router = build_api_router(store).layer(Extension(aggregator)); + let api_router = build_api_router(store, config.version).layer(Extension(aggregator)); let metrics_router = metrics::start_prometheus_metrics_api(); let debug_router = build_debug_router(); @@ -90,18 +97,20 @@ pub async fn start_rpc_server( Ok(()) } -/// Build the API router with the given store. +/// Build the API router with the given store and client version. /// -/// The aggregator controller is threaded in via `Extension` by the caller -/// (see `start_rpc_server`) so existing store-backed handlers don't need to -/// know about it and admin handlers extract it independently. -fn build_api_router(store: Store) -> Router { +/// `version` is `RpcConfig::version`, captured by the `/lean/v0/node/identity` +/// route so it can report it. The aggregator controller is threaded in +/// separately via `Extension` by the caller (see `start_rpc_server`) so +/// existing store-backed handlers don't need to know about it and admin +/// handlers extract it independently. +fn build_api_router(store: Store, version: &'static str) -> Router { Router::new() .merge(base::routes()) .merge(blocks::routes()) .merge(fork_choice::routes()) .merge(admin::routes()) - .merge(node::routes()) + .merge(node::routes(version)) .with_state(store) } @@ -211,7 +220,7 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store.clone()); + let app = build_api_router(store.clone(), "ethlambda/test"); let response = app .oneshot( @@ -253,7 +262,7 @@ mod tests { expected_state.latest_block_header.state_root = H256::ZERO; let expected_ssz = expected_state.to_ssz(); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = app .oneshot( @@ -324,7 +333,7 @@ mod tests { let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}")).await; @@ -345,7 +354,7 @@ mod tests { let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}/header")).await; @@ -361,7 +370,7 @@ mod tests { #[tokio::test] async fn get_block_by_slot_returns_json() { let (store, _target_root) = store_with_historical_block(); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = send(app, "/lean/v0/blocks/1").await; @@ -378,7 +387,7 @@ mod tests { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = send(app, "/lean/v0/blocks/not-a-valid-id").await; @@ -390,7 +399,7 @@ mod tests { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let missing = format!("0x{}", "aa".repeat(32)); let response = send(app, &format!("/lean/v0/blocks/{missing}")).await; @@ -401,7 +410,7 @@ mod tests { #[tokio::test] async fn get_block_missing_slot_returns_404() { let (store, _) = store_with_historical_block(); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = send(app, "/lean/v0/blocks/999").await; @@ -411,7 +420,7 @@ mod tests { #[tokio::test] async fn get_block_empty_slot_returns_404() { let (store, _) = store_with_historical_block(); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); // Slot 0 in the test setup is H256::ZERO (empty). let response = send(app, "/lean/v0/blocks/0").await; @@ -464,7 +473,7 @@ mod tests { let expected_ssz = signed_block.to_ssz(); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = app .oneshot( @@ -511,7 +520,7 @@ mod tests { }; let expected_ssz = expected.to_ssz(); - let app = build_api_router(store); + let app = build_api_router(store, "ethlambda/test"); let response = app .oneshot( diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index 1b0103b6..7ea20a3d 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -39,16 +39,18 @@ async fn get_syncing(State(store): State) -> impl IntoResponse { }) } -async fn get_identity() -> impl IntoResponse { - json_response(IdentityResponse { - version: env!("CARGO_PKG_VERSION"), - }) +/// Returns the full client version string, identical to `ethlambda --version` +/// (e.g. `ethlambda/v0.1.0-main-892ad575/x86_64-unknown-linux-gnu/rustc-v1.85.0`): +/// semver, git branch and short SHA, target triple, and rustc version. Sourced +/// from `RpcConfig::version` and captured by the route in `routes`. +async fn get_identity(version: &'static str) -> impl IntoResponse { + json_response(IdentityResponse { version }) } -pub(crate) fn routes() -> Router { +pub(crate) fn routes(version: &'static str) -> Router { Router::new() .route("/lean/v0/node/syncing", get(get_syncing)) - .route("/lean/v0/node/identity", get(get_identity)) + .route("/lean/v0/node/identity", get(move || get_identity(version))) } #[cfg(test)] @@ -68,7 +70,7 @@ mod tests { /// Helper: GET /lean/v0/node/syncing and parse JSON body. async fn get_syncing_json(store: Store) -> serde_json::Value { - let app = crate::build_api_router(store); + let app = crate::build_api_router(store, "ethlambda/test"); let resp = app .oneshot( Request::builder() @@ -117,8 +119,12 @@ mod tests { #[tokio::test] async fn node_identity_reports_version() { + // The binary injects the real `CLIENT_VERSION` via this Extension layer; + // here we inject a sentinel and assert it round-trips verbatim. + const VERSION: &str = + "ethlambda/v9.9.9-test-deadbeef/x86_64-unknown-linux-gnu/rustc-v1.92.0"; let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); - let app = crate::build_api_router(store); + let app = crate::build_api_router(store, VERSION); let resp = app .oneshot( Request::builder() @@ -131,6 +137,6 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); let body = resp.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(json["version"].is_string()); + assert_eq!(json["version"], VERSION); } } diff --git a/docs/rpc.md b/docs/rpc.md index 1ad4cddd..43bfa319 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -96,10 +96,12 @@ The fork-choice tree from the finalized root, with LMD-GHOST weights computed ov ### `GET /lean/v0/node/identity` ```json -{ "version": "0.1.0" } +{ "version": "ethlambda/v0.1.0-main-892ad575/x86_64-unknown-linux-gnu/rustc-v1.85.0" } ``` -The crate version baked in at compile time (`CARGO_PKG_VERSION`). +The full client version string, identical to what `ethlambda --version` prints: +crate semver, git branch and short SHA, target triple, and rustc version. Baked +in at compile time from `CARGO_PKG_VERSION` plus the `vergen-git2` build metadata. ### `GET /lean/v0/node/syncing` From 61309a26678b47ca23c17bee4f53c5d5a0bcd890 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, 6 Jul 2026 15:50:16 -0300 Subject: [PATCH 5/9] fix(rpc): make node/syncing is_syncing match the sync-status metric get_syncing recomputed is_syncing as a stateless snapshot (sync_distance > SYNC_LAG_THRESHOLD), ignoring the hysteresis and network-stall override in the actor's SyncStatusTracker. The endpoint could therefore report syncing while the node considered itself synced (and vice versa), disagreeing with both duty gating and the lean_node_sync_status metric. Read the status back from the metric instead (new metrics::node_sync_status getter) so is_syncing equals lean_node_sync_status by construction. sync_distance stays a raw store-vs-wall-clock snapshot for information; the docs now note the two can differ near the threshold or during a network stall. --- crates/blockchain/src/metrics.rs | 32 +++++++++++++++++++ crates/net/rpc/src/node.rs | 53 ++++++++++++++++++++++++-------- docs/rpc.md | 4 ++- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 28e510f5..5b512e61 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -902,3 +902,35 @@ pub fn set_node_sync_status(status: SyncStatus) { .set(i64::from(*label == active)); } } + +/// Read back the node sync status currently published by `lean_node_sync_status`. +/// +/// Returns the variant whose gauge is set to 1, or [`SyncStatus::Idle`] if none +/// is (e.g. before the first tick sets it). Reading the metric back lets code +/// outside the blockchain actor — notably the RPC `/lean/v0/node/syncing` +/// endpoint — report exactly the status the metric exposes, so the endpoint and +/// the metric cannot disagree. +pub fn node_sync_status() -> SyncStatus { + [SyncStatus::Syncing, SyncStatus::Synced, SyncStatus::Idle] + .into_iter() + .find(|status| { + LEAN_NODE_SYNC_STATUS + .with_label_values(&[status.as_str()]) + .get() + == 1 + }) + .unwrap_or(SyncStatus::Idle) +} + +#[cfg(test)] +mod tests { + use super::{SyncStatus, node_sync_status, set_node_sync_status}; + + #[test] + fn node_sync_status_reads_back_what_was_set() { + for status in [SyncStatus::Syncing, SyncStatus::Synced, SyncStatus::Idle] { + set_node_sync_status(status); + assert_eq!(node_sync_status(), status); + } + } +} diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index 7ea20a3d..06ddd9c8 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -1,5 +1,6 @@ use axum::{Router, extract::State, response::IntoResponse, routing::get}; -use ethlambda_blockchain::{MILLISECONDS_PER_SLOT, SYNC_LAG_THRESHOLD}; +use ethlambda_blockchain::MILLISECONDS_PER_SLOT; +use ethlambda_blockchain::metrics::{SyncStatus, node_sync_status}; use ethlambda_storage::Store; use serde::Serialize; @@ -18,9 +19,18 @@ struct IdentityResponse { version: &'static str, } -/// Simplified sync status: head-vs-wall-clock lag only. Unlike `SyncStatusTracker` -/// it has no hysteresis or stall-override (it is stateless). Sync distance is the -/// number of slots between the node's current head and the current wall-clock slot. +/// Sync status for `/lean/v0/node/syncing`. +/// +/// `is_syncing` mirrors the `lean_node_sync_status` metric exactly: it is the +/// blockchain actor's stateful sync decision (head-vs-wall-clock lag with +/// hysteresis and a network-stall override, updated each tick), read back from +/// the metric so the endpoint and the metric can never disagree. +/// +/// `head_slot`, `finalized_slot`, and `sync_distance` are a stateless +/// per-request snapshot from the store. `sync_distance` is the raw +/// head-vs-wall-clock slot gap; because `is_syncing` carries hysteresis / +/// stall handling and is *not* recomputed from `sync_distance`, the two can +/// point different ways near the threshold or during a network stall. async fn get_syncing(State(store): State) -> impl IntoResponse { let genesis_ms = store.config().genesis_time.saturating_mul(1000); let now_ms = std::time::SystemTime::now() @@ -32,7 +42,7 @@ async fn get_syncing(State(store): State) -> impl IntoResponse { let sync_distance = wall_slot.saturating_sub(head_slot); let finalized_slot = store.latest_finalized().slot; json_response(SyncingResponse { - is_syncing: sync_distance > SYNC_LAG_THRESHOLD, + is_syncing: node_sync_status() == SyncStatus::Syncing, head_slot, sync_distance, finalized_slot, @@ -86,9 +96,10 @@ mod tests { } #[tokio::test] - async fn node_syncing_far_behind_wall_clock() { - // create_test_state() has genesis_time=1000 (year 1970), so wall_slot is huge. - // head_slot=0 → sync_distance is large → is_syncing=true. + async fn node_syncing_sync_distance_far_behind_wall_clock() { + // create_test_state() has genesis_time=1000 (year 1970), so wall_slot is + // huge and head_slot=0 → sync_distance is large. (is_syncing is driven by + // the metric, not sync_distance; see node_syncing_is_syncing_mirrors_metric.) let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); let json = get_syncing_json(store).await; assert_eq!(json["head_slot"], 0); @@ -98,12 +109,11 @@ mod tests { "expected large sync_distance, got {}", json["sync_distance"] ); - assert_eq!(json["is_syncing"], true); } #[tokio::test] - async fn node_syncing_up_to_date() { - // Set genesis_time to far future so wall_slot=0 and head_slot=0 → not syncing. + async fn node_syncing_sync_distance_up_to_date() { + // Set genesis_time to far future so wall_slot=0 and head_slot=0 → sync_distance=0. let mut state = create_test_state(); // Unix timestamp ~year 2100 (4102444800 seconds), well beyond any test run. state.config = ChainConfig { @@ -114,12 +124,29 @@ mod tests { assert_eq!(json["head_slot"], 0); assert_eq!(json["finalized_slot"], 0); assert_eq!(json["sync_distance"], 0); - assert_eq!(json["is_syncing"], false); + } + + #[tokio::test] + async fn node_syncing_is_syncing_mirrors_metric() { + use ethlambda_blockchain::metrics::{SyncStatus, set_node_sync_status}; + + // is_syncing reflects the lean_node_sync_status metric (the actor's real + // sync decision), not the raw wall-clock sync_distance. Drive the metric + // and confirm the endpoint follows it. This is the only test in this + // binary that writes the process-global gauge, so the set→read sequence + // is race-free. + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + + set_node_sync_status(SyncStatus::Syncing); + assert_eq!(get_syncing_json(store.clone()).await["is_syncing"], true); + + set_node_sync_status(SyncStatus::Synced); + assert_eq!(get_syncing_json(store).await["is_syncing"], false); } #[tokio::test] async fn node_identity_reports_version() { - // The binary injects the real `CLIENT_VERSION` via this Extension layer; + // The binary injects the real `CLIENT_VERSION` via `RpcConfig::version`; // here we inject a sentinel and assert it round-trips verbatim. const VERSION: &str = "ethlambda/v9.9.9-test-deadbeef/x86_64-unknown-linux-gnu/rustc-v1.92.0"; diff --git a/docs/rpc.md b/docs/rpc.md index 43bfa319..fe32ffb2 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -109,7 +109,9 @@ in at compile time from `CARGO_PKG_VERSION` plus the `vergen-git2` build metadat { "is_syncing": false, "head_slot": 1024, "sync_distance": 1, "finalized_slot": 986 } ``` -`sync_distance` is the number of slots between the node's current head and the current wall-clock slot; `is_syncing` is true when it exceeds `SYNC_LAG_THRESHOLD`. This is a stateless per-request snapshot: unlike the internal `SyncStatusTracker` it has no hysteresis or stall override, so it can disagree with the node's own duty gating around the threshold. +`is_syncing` mirrors the `lean_node_sync_status` metric: it is the node's own stateful sync decision (head-vs-wall-clock lag with hysteresis and a network-stall override, the same signal that gates validator duties), so the endpoint and the metric always agree. + +`sync_distance` is the raw number of slots between the node's current head and the current wall-clock slot, computed per request. Because `is_syncing` carries hysteresis and stall handling and is not recomputed from `sync_distance`, the two can point different ways near the threshold or during a network-wide stall. ### `GET` / `POST /lean/v0/admin/aggregator` From 066fdb4ad86100f097fff704007cda8cfdfe5dcf 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, 6 Jul 2026 16:34:41 -0300 Subject: [PATCH 6/9] refactor(rpc): source node/syncing is_syncing from a shared controller The previous commit read is_syncing back from the lean_node_sync_status metric; using a metric as application state is a smell. Introduce SyncStatusController (Arc, mirroring AggregatorController): the blockchain actor writes it each tick from the same SyncStatus it feeds the metric, and the RPC /lean/v0/node/syncing endpoint reads it via an Extension. The SyncStatusTracker remains the single source of truth; is_syncing, the duty gate, and the metric all derive from it, so they cannot disagree. Drops the metrics::node_sync_status getter added previously. --- bin/ethlambda/src/main.rs | 21 +++++++-- crates/blockchain/src/lib.rs | 9 ++++ crates/blockchain/src/metrics.rs | 52 ++++++++-------------- crates/blockchain/src/sync_status.rs | 56 ++++++++++++++++++++++++ crates/net/rpc/src/lib.rs | 9 +++- crates/net/rpc/src/node.rs | 64 +++++++++++++++------------- docs/rpc.md | 2 +- 7 files changed, 143 insertions(+), 70 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index b75f9d84..6ede837d 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -50,7 +50,7 @@ use serde::Deserialize; use tracing::{error, info, warn}; use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt}; -use ethlambda_blockchain::BlockChain; +use ethlambda_blockchain::{BlockChain, SyncStatusController}; use ethlambda_rpc::RpcConfig; use ethlambda_storage::{ MAX_RESUMABLE_DB_STATE_AGE, StorageBackend, Store, backend::RocksDBBackend, @@ -211,10 +211,17 @@ async fn main() -> eyre::Result<()> { // and the API server (which exposes GET/POST admin endpoints). let aggregator = AggregatorController::new(options.is_aggregator); + // Shared, runtime-readable sync status. The blockchain actor writes it each + // tick (alongside the `lean_node_sync_status` metric); the RPC + // `/lean/v0/node/syncing` endpoint reads it. Seeded to Idle, matching the + // metric's startup value. + let sync_status = SyncStatusController::default(); + let blockchain = BlockChain::spawn( store.clone(), validator_keys, aggregator.clone(), + sync_status.clone(), attestation_committee_count, !options.disable_duty_sync_gate, ProposerConfig { @@ -260,9 +267,15 @@ async fn main() -> eyre::Result<()> { let rpc_shutdown = shutdown_token.clone(); let rpc_handle = tokio::spawn(async move { - let _ = ethlambda_rpc::start_rpc_server(rpc_config, store, aggregator, rpc_shutdown) - .await - .inspect_err(|err| error!(%err, "RPC server failed")); + let _ = ethlambda_rpc::start_rpc_server( + rpc_config, + store, + aggregator, + sync_status, + rpc_shutdown, + ) + .await + .inspect_err(|err| error!(%err, "RPC server failed")); }); info!("Node initialized"); diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 43f84d04..f74c6e7a 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -52,6 +52,7 @@ pub const MILLISECONDS_PER_SLOT: u64 = MILLISECONDS_PER_INTERVAL * INTERVALS_PER pub use ethlambda_types::block::MAX_ATTESTATIONS_DATA; /// Slots of head-vs-wall-clock lag above which a node is considered syncing. pub use sync_status::SYNC_LAG_THRESHOLD; +pub use sync_status::SyncStatusController; /// Future-slot tolerance for gossip attestations, expressed in intervals. /// /// Bounds the clock skew the time check is willing to absorb when admitting a @@ -83,6 +84,7 @@ impl BlockChain { store: Store, validator_keys: HashMap, aggregator: AggregatorController, + sync_status_controller: SyncStatusController, attestation_committee_count: u64, gate_duties: bool, proposer_config: ProposerConfig, @@ -113,6 +115,7 @@ impl BlockChain { proposer_config, pre_merge_coverage: None, sync_status: SyncStatusTracker::new(gate_duties), + sync_status_controller, } .start(); let time_until_genesis = (SystemTime::UNIX_EPOCH + Duration::from_secs(genesis_time)) @@ -185,6 +188,11 @@ pub struct BlockChainServer { /// validator duties while syncing, unless that gating was disabled at /// startup via `--disable-duty-sync-gate` (then it is metric-only). sync_status: SyncStatusTracker, + + /// Shared, read-only mirror of `sync_status` for readers outside the actor + /// (the RPC `/lean/v0/node/syncing` endpoint). Written from + /// `update_sync_status` with the same `SyncStatus` fed to the metric. + sync_status_controller: SyncStatusController, } impl BlockChainServer { @@ -949,6 +957,7 @@ impl BlockChainServer { .sync_status .update(current_slot, head_slot, max_seen_slot); metrics::set_node_sync_status(status); + self.sync_status_controller.set(status); } } diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index 5b512e61..dce62656 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -491,11 +491,15 @@ static LEAN_BLOCK_PROPOSAL_AGGREGATES_SELECTED: std::sync::LazyLock = // --- Sync Status --- /// Node synchronization status. +/// +/// The explicit discriminants are the wire encoding used by +/// [`crate::SyncStatusController`] (`*self as u8` / [`SyncStatus::from_u8`]); +/// keep them stable. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncStatus { - Idle, - Syncing, - Synced, + Idle = 0, + Syncing = 1, + Synced = 2, } impl SyncStatus { @@ -507,6 +511,16 @@ impl SyncStatus { } } + /// Decode a discriminant produced by `*self as u8`. Unknown values fall + /// back to [`SyncStatus::Idle`]. + pub(crate) fn from_u8(value: u8) -> Self { + match value { + 1 => SyncStatus::Syncing, + 2 => SyncStatus::Synced, + _ => SyncStatus::Idle, + } + } + const ALL: &[&str] = &["idle", "syncing", "synced"]; } @@ -902,35 +916,3 @@ pub fn set_node_sync_status(status: SyncStatus) { .set(i64::from(*label == active)); } } - -/// Read back the node sync status currently published by `lean_node_sync_status`. -/// -/// Returns the variant whose gauge is set to 1, or [`SyncStatus::Idle`] if none -/// is (e.g. before the first tick sets it). Reading the metric back lets code -/// outside the blockchain actor — notably the RPC `/lean/v0/node/syncing` -/// endpoint — report exactly the status the metric exposes, so the endpoint and -/// the metric cannot disagree. -pub fn node_sync_status() -> SyncStatus { - [SyncStatus::Syncing, SyncStatus::Synced, SyncStatus::Idle] - .into_iter() - .find(|status| { - LEAN_NODE_SYNC_STATUS - .with_label_values(&[status.as_str()]) - .get() - == 1 - }) - .unwrap_or(SyncStatus::Idle) -} - -#[cfg(test)] -mod tests { - use super::{SyncStatus, node_sync_status, set_node_sync_status}; - - #[test] - fn node_sync_status_reads_back_what_was_set() { - for status in [SyncStatus::Syncing, SyncStatus::Synced, SyncStatus::Idle] { - set_node_sync_status(status); - assert_eq!(node_sync_status(), status); - } - } -} diff --git a/crates/blockchain/src/sync_status.rs b/crates/blockchain/src/sync_status.rs index 9130e278..c23a7f22 100644 --- a/crates/blockchain/src/sync_status.rs +++ b/crates/blockchain/src/sync_status.rs @@ -1,7 +1,49 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + use tracing::debug; use crate::metrics::SyncStatus; +/// Shared, runtime-readable node sync status. +/// +/// The blockchain actor is the sole writer: once per tick it calls +/// [`SyncStatusController::set`] with the same [`SyncStatus`] it feeds the +/// `lean_node_sync_status` metric (see `BlockChainServer::update_sync_status`). +/// Read paths outside the actor — notably the RPC `/lean/v0/node/syncing` +/// endpoint — clone the controller and read the current status without touching +/// actor state. Mirrors [`ethlambda_types::aggregator::AggregatorController`]. +#[derive(Clone, Debug)] +pub struct SyncStatusController { + status: Arc, +} + +impl SyncStatusController { + /// Construct a controller seeded with `initial` (the actor seeds + /// [`SyncStatus::Idle`], matching the metric's startup value). + pub fn new(initial: SyncStatus) -> Self { + Self { + status: Arc::new(AtomicU8::new(initial as u8)), + } + } + + /// Publish the current sync status. + pub fn set(&self, status: SyncStatus) { + self.status.store(status as u8, Ordering::Relaxed); + } + + /// Read the current sync status. + pub fn get(&self) -> SyncStatus { + SyncStatus::from_u8(self.status.load(Ordering::Relaxed)) + } +} + +impl Default for SyncStatusController { + fn default() -> Self { + Self::new(SyncStatus::Idle) + } +} + /// Local head lag beyond which the node is considered to be syncing. /// /// See: leanSpec PR #708. @@ -90,6 +132,20 @@ impl SyncStatusTracker { mod tests { use super::*; + #[test] + fn sync_status_controller_round_trips_and_shares() { + let controller = SyncStatusController::default(); + assert_eq!(controller.get(), SyncStatus::Idle); + + controller.set(SyncStatus::Syncing); + assert_eq!(controller.get(), SyncStatus::Syncing); + + // Clones share the same Arc, so a write is visible through either handle. + let clone = controller.clone(); + controller.set(SyncStatus::Synced); + assert_eq!(clone.get(), SyncStatus::Synced); + } + #[test] fn sync_status_allows_lag_through_threshold() { let mut tracker = SyncStatusTracker::default(); diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 5420b64a..b0988766 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -1,6 +1,7 @@ use std::net::{IpAddr, SocketAddr}; use axum::{Extension, Router}; +use ethlambda_blockchain::SyncStatusController; use ethlambda_storage::Store; use ethlambda_types::aggregator::AggregatorController; use tokio_util::sync::CancellationToken; @@ -59,9 +60,15 @@ pub async fn start_rpc_server( config: RpcConfig, store: Store, aggregator: AggregatorController, + sync_status: SyncStatusController, shutdown: CancellationToken, ) -> Result<(), std::io::Error> { - let api_router = build_api_router(store, config.version).layer(Extension(aggregator)); + // `aggregator` and `sync_status` are shared runtime handles the blockchain + // actor writes; they reach handlers via `Extension` (see admin and node + // routes). `version` is static config threaded through `build_api_router`. + let api_router = build_api_router(store, config.version) + .layer(Extension(aggregator)) + .layer(Extension(sync_status)); let metrics_router = metrics::start_prometheus_metrics_api(); let debug_router = build_debug_router(); diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index 06ddd9c8..e5005de7 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -1,6 +1,6 @@ -use axum::{Router, extract::State, response::IntoResponse, routing::get}; -use ethlambda_blockchain::MILLISECONDS_PER_SLOT; -use ethlambda_blockchain::metrics::{SyncStatus, node_sync_status}; +use axum::{Extension, Router, extract::State, response::IntoResponse, routing::get}; +use ethlambda_blockchain::metrics::SyncStatus; +use ethlambda_blockchain::{MILLISECONDS_PER_SLOT, SyncStatusController}; use ethlambda_storage::Store; use serde::Serialize; @@ -21,17 +21,21 @@ struct IdentityResponse { /// Sync status for `/lean/v0/node/syncing`. /// -/// `is_syncing` mirrors the `lean_node_sync_status` metric exactly: it is the -/// blockchain actor's stateful sync decision (head-vs-wall-clock lag with -/// hysteresis and a network-stall override, updated each tick), read back from -/// the metric so the endpoint and the metric can never disagree. +/// `is_syncing` reads the node's own sync decision from the shared +/// [`SyncStatusController`]: head-vs-wall-clock lag with hysteresis and a +/// network-stall override, updated each tick by the blockchain actor. It is the +/// same signal that gates validator duties and drives the `lean_node_sync_status` +/// metric, so the endpoint agrees with both. /// /// `head_slot`, `finalized_slot`, and `sync_distance` are a stateless /// per-request snapshot from the store. `sync_distance` is the raw /// head-vs-wall-clock slot gap; because `is_syncing` carries hysteresis / /// stall handling and is *not* recomputed from `sync_distance`, the two can /// point different ways near the threshold or during a network stall. -async fn get_syncing(State(store): State) -> impl IntoResponse { +async fn get_syncing( + State(store): State, + Extension(sync_status): Extension, +) -> impl IntoResponse { let genesis_ms = store.config().genesis_time.saturating_mul(1000); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -42,7 +46,7 @@ async fn get_syncing(State(store): State) -> impl IntoResponse { let sync_distance = wall_slot.saturating_sub(head_slot); let finalized_slot = store.latest_finalized().slot; json_response(SyncingResponse { - is_syncing: node_sync_status() == SyncStatus::Syncing, + is_syncing: sync_status.get() == SyncStatus::Syncing, head_slot, sync_distance, finalized_slot, @@ -66,10 +70,12 @@ pub(crate) fn routes(version: &'static str) -> Router { #[cfg(test)] mod tests { use axum::{ + Extension, body::Body, http::{Request, StatusCode}, }; - use ethlambda_blockchain::SYNC_LAG_THRESHOLD; + use ethlambda_blockchain::metrics::SyncStatus; + use ethlambda_blockchain::{SYNC_LAG_THRESHOLD, SyncStatusController}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::state::ChainConfig; use http_body_util::BodyExt; @@ -78,9 +84,10 @@ mod tests { use crate::test_utils::create_test_state; - /// Helper: GET /lean/v0/node/syncing and parse JSON body. - async fn get_syncing_json(store: Store) -> serde_json::Value { - let app = crate::build_api_router(store, "ethlambda/test"); + /// Helper: GET /lean/v0/node/syncing (with the given sync controller) and + /// parse the JSON body. + async fn get_syncing_json(store: Store, sync: SyncStatusController) -> serde_json::Value { + let app = crate::build_api_router(store, "ethlambda/test").layer(Extension(sync)); let resp = app .oneshot( Request::builder() @@ -98,10 +105,10 @@ mod tests { #[tokio::test] async fn node_syncing_sync_distance_far_behind_wall_clock() { // create_test_state() has genesis_time=1000 (year 1970), so wall_slot is - // huge and head_slot=0 → sync_distance is large. (is_syncing is driven by - // the metric, not sync_distance; see node_syncing_is_syncing_mirrors_metric.) + // huge and head_slot=0 → sync_distance is large. (is_syncing comes from the + // controller, not sync_distance; see node_syncing_reflects_controller.) let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); - let json = get_syncing_json(store).await; + let json = get_syncing_json(store, SyncStatusController::default()).await; assert_eq!(json["head_slot"], 0); assert_eq!(json["finalized_slot"], 0); assert!( @@ -120,28 +127,27 @@ mod tests { genesis_time: 4_102_444_800, }; let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), state); - let json = get_syncing_json(store).await; + let json = get_syncing_json(store, SyncStatusController::default()).await; assert_eq!(json["head_slot"], 0); assert_eq!(json["finalized_slot"], 0); assert_eq!(json["sync_distance"], 0); } #[tokio::test] - async fn node_syncing_is_syncing_mirrors_metric() { - use ethlambda_blockchain::metrics::{SyncStatus, set_node_sync_status}; - - // is_syncing reflects the lean_node_sync_status metric (the actor's real - // sync decision), not the raw wall-clock sync_distance. Drive the metric - // and confirm the endpoint follows it. This is the only test in this - // binary that writes the process-global gauge, so the set→read sequence - // is race-free. + async fn node_syncing_reflects_controller() { + // is_syncing comes from the shared SyncStatusController (the actor's sync + // decision), not the raw wall-clock sync_distance. It follows the + // controller and updates through the shared handle without rebuilding it. let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); + let sync = SyncStatusController::new(SyncStatus::Syncing); - set_node_sync_status(SyncStatus::Syncing); - assert_eq!(get_syncing_json(store.clone()).await["is_syncing"], true); + assert_eq!( + get_syncing_json(store.clone(), sync.clone()).await["is_syncing"], + true + ); - set_node_sync_status(SyncStatus::Synced); - assert_eq!(get_syncing_json(store).await["is_syncing"], false); + sync.set(SyncStatus::Synced); + assert_eq!(get_syncing_json(store, sync).await["is_syncing"], false); } #[tokio::test] diff --git a/docs/rpc.md b/docs/rpc.md index fe32ffb2..4be642fc 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -109,7 +109,7 @@ in at compile time from `CARGO_PKG_VERSION` plus the `vergen-git2` build metadat { "is_syncing": false, "head_slot": 1024, "sync_distance": 1, "finalized_slot": 986 } ``` -`is_syncing` mirrors the `lean_node_sync_status` metric: it is the node's own stateful sync decision (head-vs-wall-clock lag with hysteresis and a network-stall override, the same signal that gates validator duties), so the endpoint and the metric always agree. +`is_syncing` is the node's own stateful sync decision: head-vs-wall-clock lag with hysteresis and a network-stall override, updated each tick. It is the same signal that gates validator duties and drives the `lean_node_sync_status` metric, so the endpoint, the gate, and the metric always agree. `sync_distance` is the raw number of slots between the node's current head and the current wall-clock slot, computed per request. Because `is_syncing` carries hysteresis and stall handling and is not recomputed from `sync_distance`, the two can point different ways near the threshold or during a network-wide stall. From 0b3f35bff5f1ed260d245681e2c27c77e554bcf2 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, 6 Jul 2026 16:47:38 -0300 Subject: [PATCH 7/9] test: drop trivial SyncStatusController round-trip test --- crates/blockchain/src/sync_status.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/blockchain/src/sync_status.rs b/crates/blockchain/src/sync_status.rs index c23a7f22..f58ade06 100644 --- a/crates/blockchain/src/sync_status.rs +++ b/crates/blockchain/src/sync_status.rs @@ -132,20 +132,6 @@ impl SyncStatusTracker { mod tests { use super::*; - #[test] - fn sync_status_controller_round_trips_and_shares() { - let controller = SyncStatusController::default(); - assert_eq!(controller.get(), SyncStatus::Idle); - - controller.set(SyncStatus::Syncing); - assert_eq!(controller.get(), SyncStatus::Syncing); - - // Clones share the same Arc, so a write is visible through either handle. - let clone = controller.clone(); - controller.set(SyncStatus::Synced); - assert_eq!(clone.get(), SyncStatus::Synced); - } - #[test] fn sync_status_allows_lag_through_threshold() { let mut tracker = SyncStatusTracker::default(); From 5e4aa6c9cc95600cc4da9661fa0c046e69609c1e 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, 6 Jul 2026 17:50:01 -0300 Subject: [PATCH 8/9] feat(rpc): add peer_id to node/identity The node/identity endpoint reported only the client version; add the node's libp2p peer ID so health/orchestration tooling can identify the node on the wire without scraping logs. Expose local_peer_id on BuiltSwarm (already derived from the node key in build_swarm), read it in main.rs after the swarm is built, and thread it into start_rpc_server as a String. It is threaded as a separate argument rather than via RpcConfig because it is derived after config construction and does not exist in test-driver mode (which skips the node key). --- bin/ethlambda/src/main.rs | 5 +++++ crates/net/p2p/src/lib.rs | 4 ++++ crates/net/rpc/src/lib.rs | 41 +++++++++++++++++++------------------- crates/net/rpc/src/node.rs | 34 +++++++++++++++++++------------ crates/net/rpc/src/spec.rs | 2 +- docs/rpc.md | 13 +++++++----- 6 files changed, 60 insertions(+), 39 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 8880ae19..4b1b0070 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -249,6 +249,10 @@ async fn main() -> eyre::Result<()> { }) .wrap_err("failed to build swarm")?; + // Capture the local peer ID before `built` is moved into the P2P actor; the + // RPC `/lean/v0/node/identity` endpoint reports it. + let local_peer_id = built.local_peer_id.to_string(); + let p2p = P2P::spawn(built, store.clone(), node_names); // Wire actors together via protocol refs @@ -275,6 +279,7 @@ async fn main() -> eyre::Result<()> { store, aggregator, sync_status, + local_peer_id, rpc_shutdown, ) .await diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index de34e469..ba0318d3 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -174,6 +174,9 @@ pub struct SwarmConfig { /// Result of building the swarm — contains all pieces needed to start the P2P actor. pub struct BuiltSwarm { + /// This node's libp2p peer ID, derived from the node key. Exposed so the + /// caller can report it (e.g. via the RPC `/lean/v0/node/identity` endpoint). + pub local_peer_id: PeerId, pub(crate) swarm: libp2p::Swarm, pub(crate) attestation_topics: HashMap, pub(crate) attestation_committee_count: u64, @@ -339,6 +342,7 @@ pub fn build_swarm( info!(socket=%config.listening_socket, "P2P node started"); Ok(BuiltSwarm { + local_peer_id, swarm, attestation_topics, attestation_committee_count: config.attestation_committee_count, diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index b44ce11c..7940eb30 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -63,9 +63,10 @@ pub async fn start_rpc_server( store: Store, aggregator: AggregatorController, sync_status: SyncStatusController, + peer_id: String, shutdown: CancellationToken, ) -> Result<(), std::io::Error> { - let api_router = build_api_router(store, config.version) + let api_router = build_api_router(store, config.version, peer_id) .layer(Extension(aggregator)) .layer(Extension(sync_status)); let metrics_router = metrics::start_prometheus_metrics_api(); @@ -103,20 +104,20 @@ pub async fn start_rpc_server( Ok(()) } -/// Build the API router with the given store and client version. +/// Build the API router with the given store, client version, and peer ID. /// -/// `version` is `RpcConfig::version`, captured by the `/lean/v0/node/identity` -/// route so it can report it. The aggregator controller is threaded in -/// separately via `Extension` by the caller (see `start_rpc_server`) so -/// existing store-backed handlers don't need to know about it and admin -/// handlers extract it independently. -fn build_api_router(store: Store, version: &'static str) -> Router { +/// `version` (`RpcConfig::version`) and `peer_id` (the node's libp2p peer ID) +/// are captured by the `/lean/v0/node/identity` route so it can report them. +/// The aggregator controller is threaded in separately via `Extension` by the +/// caller (see `start_rpc_server`) so existing store-backed handlers don't need +/// to know about it and admin handlers extract it independently. +fn build_api_router(store: Store, version: &'static str, peer_id: String) -> Router { Router::new() .merge(base::routes()) .merge(blocks::routes()) .merge(fork_choice::routes()) .merge(admin::routes()) - .merge(node::routes(version)) + .merge(node::routes(version, peer_id)) .merge(genesis::routes()) .merge(spec::routes()) .with_state(store) @@ -228,7 +229,7 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store.clone(), "ethlambda/test"); + let app = build_api_router(store.clone(), "ethlambda/test", "test-peer".to_string()); let response = app .oneshot( @@ -270,7 +271,7 @@ mod tests { expected_state.latest_block_header.state_root = H256::ZERO; let expected_ssz = expected_state.to_ssz(); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = app .oneshot( @@ -341,7 +342,7 @@ mod tests { let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}")).await; @@ -362,7 +363,7 @@ mod tests { let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}/header")).await; @@ -378,7 +379,7 @@ mod tests { #[tokio::test] async fn get_block_by_slot_returns_json() { let (store, _target_root) = store_with_historical_block(); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = send(app, "/lean/v0/blocks/1").await; @@ -395,7 +396,7 @@ mod tests { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = send(app, "/lean/v0/blocks/not-a-valid-id").await; @@ -407,7 +408,7 @@ mod tests { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let missing = format!("0x{}", "aa".repeat(32)); let response = send(app, &format!("/lean/v0/blocks/{missing}")).await; @@ -418,7 +419,7 @@ mod tests { #[tokio::test] async fn get_block_missing_slot_returns_404() { let (store, _) = store_with_historical_block(); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = send(app, "/lean/v0/blocks/999").await; @@ -428,7 +429,7 @@ mod tests { #[tokio::test] async fn get_block_empty_slot_returns_404() { let (store, _) = store_with_historical_block(); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); // Slot 0 in the test setup is H256::ZERO (empty). let response = send(app, "/lean/v0/blocks/0").await; @@ -481,7 +482,7 @@ mod tests { let expected_ssz = signed_block.to_ssz(); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = app .oneshot( @@ -528,7 +529,7 @@ mod tests { }; let expected_ssz = expected.to_ssz(); - let app = build_api_router(store, "ethlambda/test"); + let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); let response = app .oneshot( diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index e5005de7..0bc9ef4c 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -17,6 +17,8 @@ struct SyncingResponse { #[derive(Serialize)] struct IdentityResponse { version: &'static str, + /// This node's libp2p peer ID (base58), as it appears to peers on the wire. + peer_id: String, } /// Sync status for `/lean/v0/node/syncing`. @@ -53,18 +55,21 @@ async fn get_syncing( }) } -/// Returns the full client version string, identical to `ethlambda --version` -/// (e.g. `ethlambda/v0.1.0-main-892ad575/x86_64-unknown-linux-gnu/rustc-v1.85.0`): -/// semver, git branch and short SHA, target triple, and rustc version. Sourced -/// from `RpcConfig::version` and captured by the route in `routes`. -async fn get_identity(version: &'static str) -> impl IntoResponse { - json_response(IdentityResponse { version }) +/// Reports node identity: the full client version string (identical to +/// `ethlambda --version`: semver, git branch and short SHA, target triple, and +/// rustc version) and the node's libp2p peer ID. Both are fixed at startup and +/// captured by the route in `routes`. +async fn get_identity(version: &'static str, peer_id: String) -> impl IntoResponse { + json_response(IdentityResponse { version, peer_id }) } -pub(crate) fn routes(version: &'static str) -> Router { +pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { Router::new() .route("/lean/v0/node/syncing", get(get_syncing)) - .route("/lean/v0/node/identity", get(move || get_identity(version))) + .route( + "/lean/v0/node/identity", + get(move || get_identity(version, peer_id.clone())), + ) } #[cfg(test)] @@ -87,7 +92,8 @@ mod tests { /// Helper: GET /lean/v0/node/syncing (with the given sync controller) and /// parse the JSON body. async fn get_syncing_json(store: Store, sync: SyncStatusController) -> serde_json::Value { - let app = crate::build_api_router(store, "ethlambda/test").layer(Extension(sync)); + let app = crate::build_api_router(store, "ethlambda/test", "test-peer".to_string()) + .layer(Extension(sync)); let resp = app .oneshot( Request::builder() @@ -151,13 +157,14 @@ mod tests { } #[tokio::test] - async fn node_identity_reports_version() { - // The binary injects the real `CLIENT_VERSION` via `RpcConfig::version`; - // here we inject a sentinel and assert it round-trips verbatim. + async fn node_identity_reports_version_and_peer_id() { + // The binary injects the real `CLIENT_VERSION` and local peer ID; here we + // inject sentinels and assert they round-trip verbatim. const VERSION: &str = "ethlambda/v9.9.9-test-deadbeef/x86_64-unknown-linux-gnu/rustc-v1.92.0"; + const PEER_ID: &str = "16Uiu2HAmTestPeerIdSentinel"; let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); - let app = crate::build_api_router(store, VERSION); + let app = crate::build_api_router(store, VERSION, PEER_ID.to_string()); let resp = app .oneshot( Request::builder() @@ -171,5 +178,6 @@ mod tests { let body = resp.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["version"], VERSION); + assert_eq!(json["peer_id"], PEER_ID); } } diff --git a/crates/net/rpc/src/spec.rs b/crates/net/rpc/src/spec.rs index aca8cbd3..f2f3ff16 100644 --- a/crates/net/rpc/src/spec.rs +++ b/crates/net/rpc/src/spec.rs @@ -54,7 +54,7 @@ mod tests { #[tokio::test] async fn spec_returns_lean_constants() { let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); - let app = crate::build_api_router(store, "ethlambda/test"); + let app = crate::build_api_router(store, "ethlambda/test", "test-peer".to_string()); let resp = app .oneshot( Request::builder() diff --git a/docs/rpc.md b/docs/rpc.md index ed66a2ff..2ec8c44c 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -32,7 +32,7 @@ If `--api-port` and `--metrics-port` are equal, all routers are merged onto a si | `GET` | `/lean/v0/blocks/{block_id}/header` | JSON | Block header by root or slot | | `GET` | `/lean/v0/fork_choice` | JSON | Fork-choice tree with per-block weights | | `GET` | `/lean/v0/fork_choice/ui` | HTML | Interactive D3.js visualization | -| `GET` | `/lean/v0/node/identity` | JSON | Client version | +| `GET` | `/lean/v0/node/identity` | JSON | Client version and libp2p peer ID | | `GET` | `/lean/v0/node/syncing` | JSON | Sync status relative to the wall clock | | `GET` | `/lean/v0/admin/aggregator` | JSON | Current aggregator role | | `POST` | `/lean/v0/admin/aggregator` | JSON | Toggle aggregator role at runtime | @@ -122,12 +122,15 @@ The fork-choice tree from the finalized root, with LMD-GHOST weights computed ov ### `GET /lean/v0/node/identity` ```json -{ "version": "ethlambda/v0.1.0-main-892ad575/x86_64-unknown-linux-gnu/rustc-v1.85.0" } +{ + "version": "ethlambda/v0.1.0-main-892ad575/x86_64-unknown-linux-gnu/rustc-v1.85.0", + "peer_id": "16Uiu2HAm7v1x…" +} ``` -The full client version string, identical to what `ethlambda --version` prints: -crate semver, git branch and short SHA, target triple, and rustc version. Baked -in at compile time from `CARGO_PKG_VERSION` plus the `vergen-git2` build metadata. +`version` is the full client version string, identical to what `ethlambda --version` prints: crate semver, git branch and short SHA, target triple, and rustc version. Baked in at compile time from `CARGO_PKG_VERSION` plus the `vergen-git2` build metadata. + +`peer_id` is the node's libp2p peer ID (base58), derived from the node key and fixed for the lifetime of the process; it matches the identity the node presents to peers on the wire. ### `GET /lean/v0/node/syncing` From cbb6ed428df0e81f3f7f4c3b9645a723d65413d2 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, 6 Jul 2026 17:58:38 -0300 Subject: [PATCH 9/9] test(rpc): add test_api_router helper for build_api_router calls build_api_router grew to three args (store, version, peer_id), and ~15 tests that don't care about identity repeated the placeholder version and peer_id. Add test_utils::test_api_router(store) that fills those in, and route the don't-care call sites through it. Tests that assert on the identity values still call build_api_router directly. --- crates/net/rpc/src/lib.rs | 32 ++++++++++++++++++++------------ crates/net/rpc/src/node.rs | 3 +-- crates/net/rpc/src/spec.rs | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 7940eb30..1ad16656 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -136,7 +136,8 @@ fn build_debug_router() -> Router { #[cfg(test)] pub(crate) mod test_utils { - use ethlambda_storage::{StorageBackend, Table}; + use axum::Router; + use ethlambda_storage::{StorageBackend, Store, Table}; use ethlambda_types::{ block::{Block, BlockBody, BlockHeader}, checkpoint::Checkpoint, @@ -145,6 +146,13 @@ pub(crate) mod test_utils { }; use libssz::SszEncode; + /// Build the API router the way tests do, with placeholder client version + /// and peer ID. Tests that assert on those identity values (e.g. the + /// `/lean/v0/node/identity` test) call `crate::build_api_router` directly. + pub(crate) fn test_api_router(store: Store) -> Router { + crate::build_api_router(store, "ethlambda/test", "test-peer".to_string()) + } + /// Create a minimal test state for testing. pub(crate) fn create_test_state() -> State { let genesis_header = BlockHeader { @@ -229,7 +237,7 @@ mod tests { let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store.clone(), "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store.clone()); let response = app .oneshot( @@ -271,7 +279,7 @@ mod tests { expected_state.latest_block_header.state_root = H256::ZERO; let expected_ssz = expected_state.to_ssz(); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = app .oneshot( @@ -342,7 +350,7 @@ mod tests { let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}")).await; @@ -363,7 +371,7 @@ mod tests { let anchor_root = anchor_root_of(&state); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}/header")).await; @@ -379,7 +387,7 @@ mod tests { #[tokio::test] async fn get_block_by_slot_returns_json() { let (store, _target_root) = store_with_historical_block(); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = send(app, "/lean/v0/blocks/1").await; @@ -396,7 +404,7 @@ mod tests { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = send(app, "/lean/v0/blocks/not-a-valid-id").await; @@ -408,7 +416,7 @@ mod tests { let state = create_test_state(); let backend = Arc::new(InMemoryBackend::new()); let store = Store::from_anchor_state(backend, state); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let missing = format!("0x{}", "aa".repeat(32)); let response = send(app, &format!("/lean/v0/blocks/{missing}")).await; @@ -419,7 +427,7 @@ mod tests { #[tokio::test] async fn get_block_missing_slot_returns_404() { let (store, _) = store_with_historical_block(); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = send(app, "/lean/v0/blocks/999").await; @@ -429,7 +437,7 @@ mod tests { #[tokio::test] async fn get_block_empty_slot_returns_404() { let (store, _) = store_with_historical_block(); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); // Slot 0 in the test setup is H256::ZERO (empty). let response = send(app, "/lean/v0/blocks/0").await; @@ -482,7 +490,7 @@ mod tests { let expected_ssz = signed_block.to_ssz(); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = app .oneshot( @@ -529,7 +537,7 @@ mod tests { }; let expected_ssz = expected.to_ssz(); - let app = build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = test_utils::test_api_router(store); let response = app .oneshot( diff --git a/crates/net/rpc/src/node.rs b/crates/net/rpc/src/node.rs index 0bc9ef4c..5d898e8a 100644 --- a/crates/net/rpc/src/node.rs +++ b/crates/net/rpc/src/node.rs @@ -92,8 +92,7 @@ mod tests { /// Helper: GET /lean/v0/node/syncing (with the given sync controller) and /// parse the JSON body. async fn get_syncing_json(store: Store, sync: SyncStatusController) -> serde_json::Value { - let app = crate::build_api_router(store, "ethlambda/test", "test-peer".to_string()) - .layer(Extension(sync)); + let app = crate::test_utils::test_api_router(store).layer(Extension(sync)); let resp = app .oneshot( Request::builder() diff --git a/crates/net/rpc/src/spec.rs b/crates/net/rpc/src/spec.rs index f2f3ff16..01c593aa 100644 --- a/crates/net/rpc/src/spec.rs +++ b/crates/net/rpc/src/spec.rs @@ -54,7 +54,7 @@ mod tests { #[tokio::test] async fn spec_returns_lean_constants() { let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), create_test_state()); - let app = crate::build_api_router(store, "ethlambda/test", "test-peer".to_string()); + let app = crate::test_utils::test_api_router(store); let resp = app .oneshot( Request::builder()