ethlambda exposes HTTP over two independent Axum servers on separate ports, so the API and the metrics/debug surface can have different network policies:
- API server — consensus data (blocks, states, checkpoints, fork choice) and admin controls.
- Metrics & debug server — Prometheus metrics and heap-profiling endpoints. No store access.
All consensus API paths are versioned under the /lean/v0 prefix. Roots are serialized as 0x-prefixed hex strings.
| Flag | Default | Description |
|---|---|---|
--http-address |
127.0.0.1 |
Bind address shared by both servers |
--api-port |
5052 |
API server port |
--metrics-port |
5054 |
Metrics & debug server port |
If --api-port and --metrics-port are equal, all routers are merged onto a single port.
| Method | Path | Response | Description |
|---|---|---|---|
GET |
/lean/v0/health |
JSON | Liveness check |
GET |
/lean/v0/config/spec |
JSON | Protocol constants the node runs with |
GET |
/lean/v0/genesis |
JSON | Genesis time and validator count |
GET |
/lean/v0/states/finalized |
SSZ | Latest finalized State |
GET |
/lean/v0/blocks/finalized |
SSZ | Latest finalized SignedBlock |
GET |
/lean/v0/checkpoints/justified |
JSON | Latest justified Checkpoint |
GET |
/lean/v0/blocks/{block_id} |
JSON | Block by root or slot |
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 |
The handler emits a fixed, compact body (no whitespace):
{"status":"healthy","service":"lean-rpc-api"}Protocol constants the node was built with. Keys mirror the leanSpec constant names:
{
"MILLISECONDS_PER_SLOT": 4000,
"INTERVALS_PER_SLOT": 5,
"MILLISECONDS_PER_INTERVAL": 800,
"HISTORICAL_ROOTS_LIMIT": 262144,
"FORK_DIGEST": "12345678"
}FORK_DIGEST is the 4-byte hex string (no 0x prefix) embedded in gossipsub topic names.
{ "genesis_time": 1770407233, "validator_count": 16 }validator_count is read from the head state's validator registry. Lean validators are fixed at genesis (no churn), so it always equals the size of the genesis registry.
SSZ-encoded State at the latest finalized checkpoint (Content-Type: application/octet-stream). The served state has its latest_block_header.state_root zeroed to match the canonical post-state representation the state transition produces, so checkpoint-sync peers reconstruct an identical state root. See Checkpoint Sync.
SSZ-encoded SignedBlock at the latest finalized checkpoint. The genesis/anchor block has no stored signature, so a placeholder blank proof is synthesized and the endpoint still returns 200. Returns 404 only in the rare case where a non-genesis finalized block's signature has been pruned below the finalized boundary and can no longer be served.
{ "slot": 128, "root": "0x1a2b…" }block_id is either:
- a
0x-prefixed 32-byte hex root, or - a decimal slot.
Slot lookups resolve through the head state's historical_block_hashes, so only canonical blocks are reachable by slot; blocks on side forks must be addressed by their root. The /header variant returns just the BlockHeader.
| Status | Condition |
|---|---|
200 |
Block (or header) found, returned as JSON |
400 |
block_id is neither a valid 0x root nor a decimal slot |
404 |
No block at that root, or the slot is empty / out of range |
Error bodies are JSON: { "error": "invalid block_id" } / { "error": "block not found" }.
The fork-choice tree from the finalized root, with LMD-GHOST weights computed over the live chain and currently known attestations.
{
"nodes": [
{ "root": "0x…", "slot": 128, "parent_root": "0x…", "proposer_index": 3, "weight": 12 }
],
"head": "0x…",
"justified": { "slot": 128, "root": "0x…" },
"finalized": { "slot": 96, "root": "0x…" },
"safe_target": "0x…",
"validator_count": 16
}/lean/v0/fork_choice/ui serves an interactive D3.js page rendering this data. See Fork Choice Visualization.
{
"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.
{ "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.
Toggle the aggregator role at runtime without restarting the node (hot-standby model, ported from leanSpec PR #636).
# Read current role
curl http://127.0.0.1:5052/lean/v0/admin/aggregator
# → {"is_aggregator": true}
# Toggle role; body must be a JSON boolean
curl -X POST http://127.0.0.1:5052/lean/v0/admin/aggregator \
-H 'content-type: application/json' -d '{"enabled": false}'
# → {"is_aggregator": false, "previous": true}| Status | Condition |
|---|---|
200 |
Role read / set |
400 |
Missing/malformed body, missing enabled, or enabled not a JSON boolean (integers 0/1 and strings are rejected) |
503 |
Aggregator controller not wired (does not occur in normal main.rs boot) |
Note: Runtime toggles do not resubscribe gossip subnets, which are frozen at startup. A standby aggregator should boot with
--is-aggregator=true(so subscriptions are in place), then use this endpoint to rotate duties. See the CLAUDE.md "Runtime Aggregator Toggle" notes for the operational model.
| Method | Path | Response | Description |
|---|---|---|---|
GET |
/metrics |
text | Prometheus-format metrics |
GET |
/health |
JSON | Liveness check (same payload as the API health endpoint) |
GET |
/debug/pprof/allocs |
pprof | jemalloc heap profile |
GET |
/debug/pprof/allocs/flamegraph |
SVG | jemalloc heap flamegraph |
The metrics endpoint reads from the global Prometheus registry and needs no store access. See Metrics for the full list of exposed series.
Heap-profiling endpoints are backed by jemalloc's built-in profiler and are only functional on Linux; other platforms return 501 Not Implemented. On Linux they return 500 if profiling was not enabled at startup.
When the binary boots with HIVE_LEAN_TEST_DRIVER=1 (any of 1/true/yes), it runs in test-driver mode instead of the normal API server. The ethereum/hive lean simulator drives these endpoints to replay leanSpec fixtures over HTTP. The driver swaps its in-process Store on every fork_choice/init, so one container can replay many fixtures without restart.
| Method | Path | Response |
|---|---|---|
GET |
/lean/v0/health |
JSON liveness (for the hive port check) |
POST |
/lean/v0/test_driver/fork_choice/init |
204 / 400 |
POST |
/lean/v0/test_driver/fork_choice/step |
StepResponse |
POST |
/lean/v0/test_driver/state_transition/run |
StateTransitionResponse |
POST |
/lean/v0/test_driver/verify_signatures/run |
VerifySignaturesResponse |
| Kind | Content-Type |
|---|---|
| JSON | application/json; charset=utf-8 |
| SSZ | application/octet-stream |
| Prometheus metrics | text/plain; version=0.0.4; charset=utf-8 |
| HTML | text/html; charset=utf-8 |