Skip to content

Commit fefad42

Browse files
authored
Add JSON-RPC endpoints for blocks (lambdaclass#301)
## Motivation Closes lambdaclass#75. There's currently no way to inspect individual blocks over HTTP — useful for debugging, integration tests, and comparing state across clients. ## Description Adds two debug endpoints: - `GET /lean/v0/blocks/{block_id}` — returns the block as JSON - `GET /lean/v0/blocks/{block_id}/header` — returns the block header as JSON `block_id` is either a `0x`-prefixed 32-byte hex root or a decimal slot. Slot lookups use the head state's `historical_block_hashes`, so only canonical blocks are reachable by slot — side-fork blocks must be addressed by their root. Invalid ids return 400; unknown/empty slots or missing roots return 404. Supporting changes: - `Serialize` on `Block`, `BlockBody`, `AggregatedAttestation`, and `AttestationData`. Custom serializers for the two `SszList`/`SszBitlist` fields (attestations as a JSON array, aggregation_bits as a `0x`-hex string matching the beacon API convention). - New `Store::get_block`, which returns header+body without signatures and works for the genesis block (unlike `get_signed_block`, which requires a signature entry). ## How to Test ```bash curl -s http://127.0.0.1:5052/lean/v0/blocks/0x<root> | jq . curl -s http://127.0.0.1:5052/lean/v0/blocks/42 | jq . curl -s http://127.0.0.1:5052/lean/v0/blocks/42/header | jq . ``` Unit tests in `crates/net/rpc/src/lib.rs` cover happy paths (by root, by slot, header variant) and error paths (400 invalid id, 404 missing root, 404 future slot, 404 empty slot). ## Closes Closes lambdaclass#75
1 parent dead5e9 commit fefad42

8 files changed

Lines changed: 348 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ The RPC crate runs **two independent Axum servers** on separate ports, allowing
294294
- `GET /lean/v0/checkpoints/justified` — justified checkpoint (JSON)
295295
- `GET /lean/v0/fork_choice` — fork choice tree (JSON)
296296
- `GET /lean/v0/fork_choice/ui` — interactive D3.js visualization
297+
- `GET /lean/v0/blocks/{block_id}` — block as JSON; `block_id` is a `0x`-prefixed 32-byte hex root or a decimal slot
298+
- `GET /lean/v0/blocks/{block_id}/header` — block header as JSON
297299
- Requires `Store` access
298300

299301
### Metrics Server (`:5054`)

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/common/types/src/attestation.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
use libssz::SszEncode as _;
12
use libssz_derive::{HashTreeRoot, SszDecode, SszEncode};
23
use libssz_types::{SszBitlist, SszVector};
4+
use serde::{Serialize, Serializer};
35

46
use crate::{
57
block::AggregatedSignatureProof,
@@ -19,7 +21,7 @@ pub struct Attestation {
1921
}
2022

2123
/// Attestation content describing the validator's observed chain view.
22-
#[derive(Debug, Clone, PartialEq, Eq, Hash, SszEncode, SszDecode, HashTreeRoot)]
24+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, SszEncode, SszDecode, HashTreeRoot)]
2325
pub struct AttestationData {
2426
/// The slot for which the attestation is made.
2527
pub slot: u64,
@@ -57,9 +59,10 @@ pub struct SignedAttestation {
5759
pub type XmssSignature = SszVector<u8, SIGNATURE_SIZE>;
5860

5961
/// Aggregated attestation consisting of participation bits and message.
60-
#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)]
62+
#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)]
6163
pub struct AggregatedAttestation {
6264
/// Bitfield indicating which validators participated in the aggregation.
65+
#[serde(serialize_with = "serialize_aggregation_bits")]
6366
pub aggregation_bits: AggregationBits,
6467

6568
/// Combined attestation data similar to the beacon chain format.
@@ -75,6 +78,16 @@ pub struct AggregatedAttestation {
7578
/// in some collective action (attestation, signature aggregation, etc.).
7679
pub type AggregationBits = SszBitlist<4096>;
7780

81+
/// Serialize an `AggregationBits` bitlist as a `0x`-prefixed hex string of its
82+
/// SSZ encoding (matching the beacon API convention).
83+
fn serialize_aggregation_bits<S>(bits: &AggregationBits, serializer: S) -> Result<S::Ok, S::Error>
84+
where
85+
S: Serializer,
86+
{
87+
let encoded = format!("0x{}", hex::encode(bits.to_ssz()));
88+
serializer.serialize_str(&encoded)
89+
}
90+
7891
/// Returns the indices of set bits in an `AggregationBits` bitfield as validator IDs.
7992
pub fn validator_indices(bits: &AggregationBits) -> impl Iterator<Item = u64> + '_ {
8093
(0..bits.len()).filter_map(move |i| {

crates/common/types/src/block.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use serde::Serialize;
1+
use serde::{Serialize, Serializer, ser::SerializeSeq};
22

33
use libssz_derive::{HashTreeRoot, SszDecode, SszEncode};
44
use libssz_types::SszList;
@@ -142,7 +142,7 @@ pub struct BlockHeader {
142142
}
143143

144144
/// A complete block including header and body.
145-
#[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)]
145+
#[derive(Debug, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)]
146146
pub struct Block {
147147
/// The slot in which the block was proposed.
148148
pub slot: u64,
@@ -192,14 +192,29 @@ impl Block {
192192
///
193193
/// Currently, the main operation is voting. Validators submit attestations which are
194194
/// packaged into blocks.
195-
#[derive(Debug, Default, Clone, SszEncode, SszDecode, HashTreeRoot)]
195+
#[derive(Debug, Default, Clone, Serialize, SszEncode, SszDecode, HashTreeRoot)]
196196
pub struct BlockBody {
197197
/// Plain validator attestations carried in the block body.
198198
///
199199
/// Individual signatures live in the aggregated block signature list, so
200200
/// these entries contain only attestation data without per-attestation signatures.
201+
#[serde(serialize_with = "serialize_attestations")]
201202
pub attestations: AggregatedAttestations,
202203
}
203204

204205
/// List of aggregated attestations included in a block.
205206
pub type AggregatedAttestations = SszList<AggregatedAttestation, 4096>;
207+
208+
fn serialize_attestations<S>(
209+
attestations: &AggregatedAttestations,
210+
serializer: S,
211+
) -> Result<S::Ok, S::Error>
212+
where
213+
S: Serializer,
214+
{
215+
let mut seq = serializer.serialize_seq(Some(attestations.len()))?;
216+
for attestation in attestations.iter() {
217+
seq.serialize_element(attestation)?;
218+
}
219+
seq.end()
220+
}

crates/net/rpc/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ethlambda-types.workspace = true
2323
libssz.workspace = true
2424
serde.workspace = true
2525
serde_json.workspace = true
26+
hex.workspace = true
2627
tracing.workspace = true
2728
jemalloc_pprof.workspace = true
2829

crates/net/rpc/src/blocks.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
use axum::{
2+
extract::{Path, State},
3+
http::StatusCode,
4+
response::IntoResponse,
5+
};
6+
use ethlambda_storage::Store;
7+
use ethlambda_types::primitives::H256;
8+
use serde_json::json;
9+
10+
use crate::json_response;
11+
12+
/// `GET /lean/v0/blocks/:block_id` — returns the block as JSON.
13+
///
14+
/// `block_id` can be a `0x`-prefixed 32-byte hex root or a decimal slot.
15+
pub async fn get_block(
16+
Path(block_id): Path<String>,
17+
State(store): State<Store>,
18+
) -> impl IntoResponse {
19+
let root = match resolve_block_id(&store, &block_id) {
20+
Ok(root) => root,
21+
Err(err) => return err.into_response(),
22+
};
23+
24+
match store.get_block(&root) {
25+
Some(block) => json_response(block),
26+
None => BlockIdError::NotFound.into_response(),
27+
}
28+
}
29+
30+
/// `GET /lean/v0/blocks/:block_id/header` — returns the block header as JSON.
31+
pub async fn get_block_header(
32+
Path(block_id): Path<String>,
33+
State(store): State<Store>,
34+
) -> impl IntoResponse {
35+
let root = match resolve_block_id(&store, &block_id) {
36+
Ok(root) => root,
37+
Err(err) => return err.into_response(),
38+
};
39+
40+
match store.get_block_header(&root) {
41+
Some(header) => json_response(header),
42+
None => BlockIdError::NotFound.into_response(),
43+
}
44+
}
45+
46+
/// Resolve a `block_id` (hex root or decimal slot) into a block root.
47+
///
48+
/// Slot lookups use the head state's `historical_block_hashes`, so only
49+
/// canonical blocks are reachable by slot — blocks on side forks must be
50+
/// addressed by their root.
51+
fn resolve_block_id(store: &Store, block_id: &str) -> Result<H256, BlockIdError> {
52+
if let Some(hex_body) = block_id.strip_prefix("0x") {
53+
parse_root(hex_body)
54+
} else if block_id.chars().all(|c| c.is_ascii_digit()) {
55+
let slot: u64 = block_id.parse().map_err(|_| BlockIdError::Invalid)?;
56+
resolve_slot(store, slot)
57+
} else {
58+
Err(BlockIdError::Invalid)
59+
}
60+
}
61+
62+
fn parse_root(hex_body: &str) -> Result<H256, BlockIdError> {
63+
let bytes = hex::decode(hex_body).map_err(|_| BlockIdError::Invalid)?;
64+
if bytes.len() != 32 {
65+
return Err(BlockIdError::Invalid);
66+
}
67+
let mut arr = [0u8; 32];
68+
arr.copy_from_slice(&bytes);
69+
Ok(H256(arr))
70+
}
71+
72+
fn resolve_slot(store: &Store, slot: u64) -> Result<H256, BlockIdError> {
73+
let head_state = store.head_state();
74+
let root = head_state
75+
.historical_block_hashes
76+
.get(slot as usize)
77+
.ok_or(BlockIdError::NotFound)?;
78+
if root.is_zero() {
79+
return Err(BlockIdError::NotFound);
80+
}
81+
Ok(*root)
82+
}
83+
84+
#[derive(Debug)]
85+
enum BlockIdError {
86+
Invalid,
87+
NotFound,
88+
}
89+
90+
impl IntoResponse for BlockIdError {
91+
fn into_response(self) -> axum::response::Response {
92+
let (status, message) = match self {
93+
BlockIdError::Invalid => (StatusCode::BAD_REQUEST, "invalid block_id"),
94+
BlockIdError::NotFound => (StatusCode::NOT_FOUND, "block not found"),
95+
};
96+
let mut response = json_response(json!({ "error": message }));
97+
*response.status_mut() = status;
98+
response
99+
}
100+
}

0 commit comments

Comments
 (0)