Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/blockchain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ 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.
///
/// Bounds the clock skew the time check is willing to absorb when admitting a
Expand Down
2 changes: 1 addition & 1 deletion crates/blockchain/src/sync_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/net/rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}

Expand Down
136 changes: 136 additions & 0 deletions crates/net/rpc/src/node.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use axum::{Router, extract::State, response::IntoResponse, routing::get};
use ethlambda_blockchain::{MILLISECONDS_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,
finalized_slot: u64,
}
Comment on lines +8 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 finalized_slot missing from SyncingResponse

The PR description lists "finalized slot" as one of the three pieces of sync info this endpoint returns, but SyncingResponse only has head_slot and sync_distance. If the omission is intentional, the description should be updated; if it was accidentally left out, store.latest_finalized().slot is already accessible and the field can be added straightforwardly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/node.rs
Line: 8-13

Comment:
**`finalized_slot` missing from `SyncingResponse`**

The PR description lists "finalized slot" as one of the three pieces of sync info this endpoint returns, but `SyncingResponse` only has `head_slot` and `sync_distance`. If the omission is intentional, the description should be updated; if it was accidentally left out, `store.latest_finalized().slot` is already accessible and the field can be added straightforwardly.

How can I resolve this? If you propose a fix, please make it concise.


#[derive(Serialize)]
struct IdentityResponse {
version: &'static str,
}
Comment on lines +7 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 is_syncing diverges from SyncStatusTracker in two concrete cases

The SyncStatusTracker applies two overrides that this stateless endpoint ignores: (1) network-stall override — when network_lag > 8, the tracker forces syncing = false even if the local head is far behind; this endpoint will still return is_syncing: true. (2) hysteresis — once syncing, the tracker keeps syncing = true until head_lag <= threshold - band = 2; this endpoint flips back to false at head_lag <= 4. Dashboards or orchestrators relying on this field could therefore disagree with the actual operational sync gate (used to suppress validator duties), leading to false alerts or missed gates.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/node.rs
Line: 7-18

Comment:
**`is_syncing` diverges from `SyncStatusTracker` in two concrete cases**

The `SyncStatusTracker` applies two overrides that this stateless endpoint ignores: (1) **network-stall override** — when `network_lag > 8`, the tracker forces `syncing = false` even if the local head is far behind; this endpoint will still return `is_syncing: true`. (2) **hysteresis** — once syncing, the tracker keeps `syncing = true` until `head_lag <= threshold - band = 2`; this endpoint flips back to `false` at `head_lag <= 4`. Dashboards or orchestrators relying on this field could therefore disagree with the actual operational sync gate (used to suppress validator duties), leading to false alerts or missed gates.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +16 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 IdentityResponse missing peer_id and enr fields

The PR description states this endpoint "returns peer ID and ENR," but IdentityResponse only exposes version (the cargo package version). Consumers expecting a peer identifier or ENR for node discovery will find neither. Either the response struct needs peer_id and enr fields, or the endpoint description should be corrected to reflect its current scope.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/src/node.rs
Line: 15-18

Comment:
**`IdentityResponse` missing `peer_id` and `enr` fields**

The PR description states this endpoint "returns peer ID and ENR," but `IdentityResponse` only exposes `version` (the cargo package version). Consumers expecting a peer identifier or ENR for node discovery will find neither. Either the response struct needs `peer_id` and `enr` fields, or the endpoint description should be corrected to reflect its current scope.

How can I resolve this? If you propose a fix, please make it concise.


/// 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.
async fn get_syncing(State(store): State<Store>) -> 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_distance > SYNC_LAG_THRESHOLD,
head_slot,
sync_distance,
finalized_slot,
})
}

async fn get_identity() -> impl IntoResponse {
json_response(IdentityResponse {
version: env!("CARGO_PKG_VERSION"),
})
}

pub(crate) fn routes() -> Router<Store> {
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_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;

/// 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(
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_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);
assert_eq!(json["finalized_slot"], 0);
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());
}
}