diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index b933b928..4b1b0070 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, @@ -92,6 +92,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}"); @@ -213,10 +214,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 { @@ -241,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 @@ -262,9 +274,16 @@ 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, + local_peer_id, + 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 32c2ac15..9b46bd00 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -50,6 +50,9 @@ 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; +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 @@ -107,6 +110,7 @@ impl BlockChain { store: Store, validator_keys: HashMap, aggregator: AggregatorController, + sync_status_controller: SyncStatusController, attestation_committee_count: u64, gate_duties: bool, proposer_config: ProposerConfig, @@ -137,6 +141,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)) @@ -209,6 +214,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 { @@ -971,6 +981,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 28e510f5..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"]; } diff --git a/crates/blockchain/src/sync_status.rs b/crates/blockchain/src/sync_status.rs index 02c71c9f..f58ade06 100644 --- a/crates/blockchain/src/sync_status.rs +++ b/crates/blockchain/src/sync_status.rs @@ -1,11 +1,53 @@ +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. -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/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 6eec72a5..1ad16656 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; @@ -15,6 +16,7 @@ mod fork_choice; mod genesis; mod heap_profiling; pub mod metrics; +mod node; mod spec; pub mod test_driver; @@ -25,6 +27,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. @@ -53,9 +62,13 @@ pub async fn start_rpc_server( config: RpcConfig, store: Store, aggregator: AggregatorController, + sync_status: SyncStatusController, + peer_id: String, 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, peer_id) + .layer(Extension(aggregator)) + .layer(Extension(sync_status)); let metrics_router = metrics::start_prometheus_metrics_api(); let debug_router = build_debug_router(); @@ -91,17 +104,20 @@ pub async fn start_rpc_server( Ok(()) } -/// Build the API router with the given store. +/// Build the API router with the given store, client version, and peer ID. /// -/// 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` (`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, peer_id)) .merge(genesis::routes()) .merge(spec::routes()) .with_state(store) @@ -120,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, @@ -129,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 { @@ -213,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()); + let app = test_utils::test_api_router(store.clone()); let response = app .oneshot( @@ -255,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); + let app = test_utils::test_api_router(store); let response = app .oneshot( @@ -326,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); + let app = test_utils::test_api_router(store); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}")).await; @@ -347,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); + let app = test_utils::test_api_router(store); let response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}/header")).await; @@ -363,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); + let app = test_utils::test_api_router(store); let response = send(app, "/lean/v0/blocks/1").await; @@ -380,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); + let app = test_utils::test_api_router(store); let response = send(app, "/lean/v0/blocks/not-a-valid-id").await; @@ -392,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); + 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; @@ -403,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); + let app = test_utils::test_api_router(store); let response = send(app, "/lean/v0/blocks/999").await; @@ -413,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); + 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; @@ -466,7 +490,7 @@ mod tests { let expected_ssz = signed_block.to_ssz(); - let app = build_api_router(store); + let app = test_utils::test_api_router(store); let response = app .oneshot( @@ -513,7 +537,7 @@ mod tests { }; let expected_ssz = expected.to_ssz(); - let app = build_api_router(store); + 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 new file mode 100644 index 00000000..5d898e8a --- /dev/null +++ b/crates/net/rpc/src/node.rs @@ -0,0 +1,182 @@ +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; + +use crate::json_response; + +#[derive(Serialize)] +struct SyncingResponse { + is_syncing: bool, + head_slot: u64, + sync_distance: u64, + finalized_slot: u64, +} + +#[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`. +/// +/// `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, + 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) + .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(); + let sync_distance = wall_slot.saturating_sub(head_slot); + let finalized_slot = store.latest_finalized().slot; + json_response(SyncingResponse { + is_syncing: sync_status.get() == SyncStatus::Syncing, + head_slot, + sync_distance, + finalized_slot, + }) +} + +/// 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, peer_id: String) -> Router { + Router::new() + .route("/lean/v0/node/syncing", get(get_syncing)) + .route( + "/lean/v0/node/identity", + get(move || get_identity(version, peer_id.clone())), + ) +} + +#[cfg(test)] +mod tests { + use axum::{ + Extension, + body::Body, + http::{Request, StatusCode}, + }; + 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; + use std::sync::Arc; + use tower::ServiceExt; + + use crate::test_utils::create_test_state; + + /// 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::test_utils::test_api_router(store).layer(Extension(sync)); + 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(); + serde_json::from_slice(&body).unwrap() + } + + #[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 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, SyncStatusController::default()).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"] + ); + } + + #[tokio::test] + 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 { + genesis_time: 4_102_444_800, + }; + let store = Store::from_anchor_state(Arc::new(InMemoryBackend::new()), state); + 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_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); + + assert_eq!( + get_syncing_json(store.clone(), sync.clone()).await["is_syncing"], + true + ); + + sync.set(SyncStatus::Synced); + assert_eq!(get_syncing_json(store, sync).await["is_syncing"], false); + } + + #[tokio::test] + 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, PEER_ID.to_string()); + 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_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 579b0d52..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); + let app = crate::test_utils::test_api_router(store); let resp = app .oneshot( Request::builder() diff --git a/docs/rpc.md b/docs/rpc.md index c288a053..2ec8c44c 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -32,6 +32,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 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 | @@ -117,6 +119,29 @@ 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": "ethlambda/v0.1.0-main-892ad575/x86_64-unknown-linux-gnu/rustc-v1.85.0", + "peer_id": "16Uiu2HAm7v1x…" +} +``` + +`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` + +```json +{ "is_syncing": false, "head_slot": 1024, "sync_distance": 1, "finalized_slot": 986 } +``` + +`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. + ### `GET` / `POST /lean/v0/admin/aggregator` Toggle the aggregator role at runtime without restarting the node (hot-standby model, ported from leanSpec PR #636).