diff --git a/CLAUDE.md b/CLAUDE.md index c1c58d95..090e8cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,6 +294,8 @@ The RPC crate runs **two independent Axum servers** on separate ports, allowing - `GET /lean/v0/checkpoints/justified` — justified checkpoint (JSON) - `GET /lean/v0/fork_choice` — fork choice tree (JSON) - `GET /lean/v0/fork_choice/ui` — interactive D3.js visualization +- `GET /lean/v0/blocks/{block_id}` — block as JSON; `block_id` is a `0x`-prefixed 32-byte hex root or a decimal slot +- `GET /lean/v0/blocks/{block_id}/header` — block header as JSON - Requires `Store` access ### Metrics Server (`:5054`) diff --git a/Cargo.lock b/Cargo.lock index 1378eca1..569d50b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2158,6 +2158,7 @@ dependencies = [ "ethlambda-metrics", "ethlambda-storage", "ethlambda-types", + "hex", "http-body-util", "jemalloc_pprof", "libssz", diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 55f3ecf6..27f948f9 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -1,5 +1,7 @@ +use libssz::SszEncode as _; use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::{SszBitlist, SszVector}; +use serde::{Serialize, Serializer}; use crate::{ block::AggregatedSignatureProof, @@ -19,7 +21,7 @@ pub struct Attestation { } /// Attestation content describing the validator's observed chain view. -#[derive(Debug, Clone, PartialEq, Eq, Hash, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct AttestationData { /// The slot for which the attestation is made. pub slot: u64, @@ -49,9 +51,10 @@ pub struct SignedAttestation { pub type XmssSignature = SszVector; /// Aggregated attestation consisting of participation bits and message. -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct AggregatedAttestation { /// Bitfield indicating which validators participated in the aggregation. + #[serde(serialize_with = "serialize_aggregation_bits")] pub aggregation_bits: AggregationBits, /// Combined attestation data similar to the beacon chain format. @@ -67,6 +70,16 @@ pub struct AggregatedAttestation { /// in some collective action (attestation, signature aggregation, etc.). pub type AggregationBits = SszBitlist<4096>; +/// Serialize an `AggregationBits` bitlist as a `0x`-prefixed hex string of its +/// SSZ encoding (matching the beacon API convention). +fn serialize_aggregation_bits(bits: &AggregationBits, serializer: S) -> Result +where + S: Serializer, +{ + let encoded = format!("0x{}", hex::encode(bits.to_ssz())); + serializer.serialize_str(&encoded) +} + /// Returns the indices of set bits in an `AggregationBits` bitfield as validator IDs. pub fn validator_indices(bits: &AggregationBits) -> impl Iterator + '_ { (0..bits.len()).filter_map(move |i| { diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 13c14573..5e1a825b 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -1,4 +1,4 @@ -use serde::Serialize; +use serde::{Serialize, Serializer, ser::SerializeSeq}; use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::SszList; @@ -127,7 +127,7 @@ pub struct BlockHeader { } /// A complete block including header and body. -#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct Block { /// The slot in which the block was proposed. pub slot: u64, @@ -177,14 +177,29 @@ impl Block { /// /// Currently, the main operation is voting. Validators submit attestations which are /// packaged into blocks. -#[derive(Debug, Default, Clone, SszEncode, SszDecode, HashTreeRoot)] +#[derive(Debug, Default, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)] pub struct BlockBody { /// Plain validator attestations carried in the block body. /// /// Individual signatures live in the aggregated block signature list, so /// these entries contain only attestation data without per-attestation signatures. + #[serde(serialize_with = "serialize_attestations")] pub attestations: AggregatedAttestations, } /// List of aggregated attestations included in a block. pub type AggregatedAttestations = SszList; + +fn serialize_attestations( + attestations: &AggregatedAttestations, + serializer: S, +) -> Result +where + S: Serializer, +{ + let mut seq = serializer.serialize_seq(Some(attestations.len()))?; + for attestation in attestations.iter() { + seq.serialize_element(attestation)?; + } + seq.end() +} diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index c05e9a26..97acf48d 100644 --- a/crates/net/rpc/Cargo.toml +++ b/crates/net/rpc/Cargo.toml @@ -20,6 +20,7 @@ ethlambda-types.workspace = true libssz.workspace = true serde.workspace = true serde_json.workspace = true +hex.workspace = true jemalloc_pprof.workspace = true [dev-dependencies] diff --git a/crates/net/rpc/src/blocks.rs b/crates/net/rpc/src/blocks.rs new file mode 100644 index 00000000..edfa1737 --- /dev/null +++ b/crates/net/rpc/src/blocks.rs @@ -0,0 +1,100 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use ethlambda_storage::Store; +use ethlambda_types::primitives::H256; +use serde_json::json; + +use crate::json_response; + +/// `GET /lean/v0/blocks/:block_id` — returns the block as JSON. +/// +/// `block_id` can be a `0x`-prefixed 32-byte hex root or a decimal slot. +pub async fn get_block( + Path(block_id): Path, + State(store): State, +) -> impl IntoResponse { + let root = match resolve_block_id(&store, &block_id) { + Ok(root) => root, + Err(err) => return err.into_response(), + }; + + match store.get_block(&root) { + Some(block) => json_response(block), + None => BlockIdError::NotFound.into_response(), + } +} + +/// `GET /lean/v0/blocks/:block_id/header` — returns the block header as JSON. +pub async fn get_block_header( + Path(block_id): Path, + State(store): State, +) -> impl IntoResponse { + let root = match resolve_block_id(&store, &block_id) { + Ok(root) => root, + Err(err) => return err.into_response(), + }; + + match store.get_block_header(&root) { + Some(header) => json_response(header), + None => BlockIdError::NotFound.into_response(), + } +} + +/// Resolve a `block_id` (hex root or decimal slot) into a block root. +/// +/// Slot lookups use 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. +fn resolve_block_id(store: &Store, block_id: &str) -> Result { + if let Some(hex_body) = block_id.strip_prefix("0x") { + parse_root(hex_body) + } else if block_id.chars().all(|c| c.is_ascii_digit()) { + let slot: u64 = block_id.parse().map_err(|_| BlockIdError::Invalid)?; + resolve_slot(store, slot) + } else { + Err(BlockIdError::Invalid) + } +} + +fn parse_root(hex_body: &str) -> Result { + let bytes = hex::decode(hex_body).map_err(|_| BlockIdError::Invalid)?; + if bytes.len() != 32 { + return Err(BlockIdError::Invalid); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(H256(arr)) +} + +fn resolve_slot(store: &Store, slot: u64) -> Result { + let head_state = store.head_state(); + let root = head_state + .historical_block_hashes + .get(slot as usize) + .ok_or(BlockIdError::NotFound)?; + if root.is_zero() { + return Err(BlockIdError::NotFound); + } + Ok(*root) +} + +#[derive(Debug)] +enum BlockIdError { + Invalid, + NotFound, +} + +impl IntoResponse for BlockIdError { + fn into_response(self) -> axum::response::Response { + let (status, message) = match self { + BlockIdError::Invalid => (StatusCode::BAD_REQUEST, "invalid block_id"), + BlockIdError::NotFound => (StatusCode::NOT_FOUND, "block not found"), + }; + let mut response = json_response(json!({ "error": message })); + *response.status_mut() = status; + response + } +} diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 5973dc62..1f82d42d 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -8,6 +8,7 @@ use libssz::SszEncode; pub(crate) const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; pub(crate) const SSZ_CONTENT_TYPE: &str = "application/octet-stream"; +mod blocks; mod fork_choice; mod heap_profiling; pub mod metrics; @@ -47,6 +48,11 @@ fn build_api_router(store: Store) -> Router { "/lean/v0/fork_choice/ui", get(fork_choice::get_fork_choice_ui), ) + .route("/lean/v0/blocks/{block_id}", get(blocks::get_block)) + .route( + "/lean/v0/blocks/{block_id}/header", + get(blocks::get_block_header), + ) .with_state(store) } @@ -104,12 +110,14 @@ fn ssz_response(bytes: Vec) -> axum::response::Response { #[cfg(test)] pub(crate) mod test_utils { + use ethlambda_storage::{StorageBackend, Table}; use ethlambda_types::{ - block::{BlockBody, BlockHeader}, + block::{Block, BlockBody, BlockHeader}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, state::{ChainConfig, JustificationValidators, JustifiedSlots, State}, }; + use libssz::SszEncode; /// Create a minimal test state for testing. pub(crate) fn create_test_state() -> State { @@ -139,6 +147,42 @@ pub(crate) mod test_utils { justifications_validators: JustificationValidators::new(), } } + + /// Build a block at the given slot with a trivial body. + pub(crate) fn make_block(slot: u64, parent_root: H256) -> Block { + Block { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body: BlockBody::default(), + } + } + + /// Insert a block's header (and body, if non-empty) into the backend. + /// + /// This bypasses `Store::insert_signed_block`, which requires XMSS + /// signatures that are expensive to produce in tests. + pub(crate) fn insert_block_raw(backend: &dyn StorageBackend, block: &Block) -> H256 { + let header = block.header(); + let root = header.hash_tree_root(); + + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch(Table::BlockHeaders, vec![(root.to_ssz(), header.to_ssz())]) + .expect("put header"); + if header.body_root != BlockBody::default().hash_tree_root() { + batch + .put_batch( + Table::BlockBodies, + vec![(root.to_ssz(), block.body.to_ssz())], + ) + .expect("put body"); + } + batch.commit().expect("commit"); + + root + } } #[cfg(test)] @@ -225,4 +269,149 @@ mod tests { let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(body.as_ref(), expected_ssz.as_slice()); } + + mod blocks { + use super::*; + use ethlambda_types::{ + primitives::{H256, HashTreeRoot as _}, + state::JustifiedSlots, + }; + + use crate::test_utils::{insert_block_raw, make_block}; + + /// Build a store whose head state points back at `slot=1` via + /// `historical_block_hashes`, with a real block stored at that slot. + fn store_with_historical_block() -> (Store, H256) { + let backend = Arc::new(InMemoryBackend::new()); + + let target_block = make_block(1, H256::ZERO); + let target_root = insert_block_raw(backend.as_ref(), &target_block); + + let mut anchor_state = create_test_state(); + anchor_state.slot = 2; + anchor_state.latest_block_header.slot = 2; + anchor_state.latest_block_header.parent_root = target_root; + anchor_state.historical_block_hashes = + vec![H256::ZERO, target_root].try_into().unwrap(); + anchor_state.justified_slots = JustifiedSlots::with_length(2).unwrap(); + + let store = Store::from_anchor_state(backend, anchor_state); + (store, target_root) + } + + async fn send(app: axum::Router, uri: &str) -> axum::response::Response { + app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() + } + + fn anchor_root_of(state: ðlambda_types::state::State) -> H256 { + let mut state = state.clone(); + state.latest_block_header.state_root = H256::ZERO; + let state_root = state.hash_tree_root(); + state.latest_block_header.state_root = state_root; + state.latest_block_header.hash_tree_root() + } + + #[tokio::test] + async fn get_block_by_root_returns_json() { + let state = create_test_state(); + 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 response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}")).await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["slot"], 0); + assert_eq!(json["proposer_index"], 0); + assert!(json["parent_root"].is_string()); + assert!(json["state_root"].is_string()); + assert!(json["body"]["attestations"].is_array()); + } + + #[tokio::test] + async fn get_block_header_by_root_returns_json() { + let state = create_test_state(); + 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 response = send(app, &format!("/lean/v0/blocks/0x{anchor_root:x}/header")).await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["slot"], 0); + assert_eq!(json["proposer_index"], 0); + assert!(json["body_root"].is_string()); + } + + #[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 response = send(app, "/lean/v0/blocks/1").await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["slot"], 1); + assert!(json["body"]["attestations"].is_array()); + } + + #[tokio::test] + async fn get_block_invalid_id_returns_400() { + 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 response = send(app, "/lean/v0/blocks/not-a-valid-id").await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn get_block_missing_root_returns_404() { + 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 missing = format!("0x{}", "aa".repeat(32)); + let response = send(app, &format!("/lean/v0/blocks/{missing}")).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn get_block_missing_slot_returns_404() { + let (store, _) = store_with_historical_block(); + let app = build_api_router(store); + + let response = send(app, "/lean/v0/blocks/999").await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn get_block_empty_slot_returns_404() { + let (store, _) = store_with_historical_block(); + let app = build_api_router(store); + + // Slot 0 in the test setup is H256::ZERO (empty). + let response = send(app, "/lean/v0/blocks/0").await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + } } diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index c15efe04..83f12490 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -918,6 +918,27 @@ impl Store { batch.commit().expect("commit"); } + /// Get a block (header + body, no signatures) by root. + /// + /// Unlike [`get_signed_block`](Self::get_signed_block), this works for the + /// genesis block, which has no signature entry. + pub fn get_block(&self, root: &H256) -> Option { + let view = self.backend.begin_read().expect("read view"); + let key = root.to_ssz(); + + let header_bytes = view.get(Table::BlockHeaders, &key).expect("get")?; + let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header"); + + let body = if header.body_root == *EMPTY_BODY_ROOT { + BlockBody::default() + } else { + let body_bytes = view.get(Table::BlockBodies, &key).expect("get")?; + BlockBody::from_ssz_bytes(&body_bytes).expect("valid body") + }; + + Some(Block::from_header_and_body(header, body)) + } + /// Get a signed block by combining header, body, and signatures. /// /// Returns None if any of the components are not found.