Skip to content
Open
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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 15 additions & 2 deletions crates/common/types/src/attestation.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -49,9 +51,10 @@ pub struct SignedAttestation {
pub type XmssSignature = SszVector<u8, SIGNATURE_SIZE>;

/// 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.
Expand All @@ -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<S>(bits: &AggregationBits, serializer: S) -> Result<S::Ok, S::Error>
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<Item = u64> + '_ {
(0..bits.len()).filter_map(move |i| {
Expand Down
21 changes: 18 additions & 3 deletions crates/common/types/src/block.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Serialize, Serializer, ser::SerializeSeq};

use libssz_derive::{HashTreeRoot, SszDecode, SszEncode};
use libssz_types::SszList;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<AggregatedAttestation, 4096>;

fn serialize_attestations<S>(
attestations: &AggregatedAttestations,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = serializer.serialize_seq(Some(attestations.len()))?;
for attestation in attestations.iter() {
seq.serialize_element(attestation)?;
}
seq.end()
}
1 change: 1 addition & 0 deletions crates/net/rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
100 changes: 100 additions & 0 deletions crates/net/rpc/src/blocks.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
State(store): State<Store>,
) -> 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<String>,
State(store): State<Store>,
) -> 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<H256, BlockIdError> {
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<H256, BlockIdError> {
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<H256, BlockIdError> {
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)
}
Comment on lines +72 to +82
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 Full state deserialization for a single slot lookup

store.head_state() deserializes the entire beacon State — including historical_block_hashes, which grows linearly with the chain's age (32 bytes × N slots). On a long-running chain this can be hundreds of MB of SSZ parsing just to index one entry. Since this is a debug endpoint the cost is tolerable for now, but a dedicated Store::get_historical_block_hash(slot: u64) -> Option<H256> accessor that reads only the States entry and pulls the specific index would eliminate the overhead without much extra complexity.

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

Comment:
**Full state deserialization for a single slot lookup**

`store.head_state()` deserializes the entire beacon `State` — including `historical_block_hashes`, which grows linearly with the chain's age (32 bytes × N slots). On a long-running chain this can be hundreds of MB of SSZ parsing just to index one entry. Since this is a debug endpoint the cost is tolerable for now, but a dedicated `Store::get_historical_block_hash(slot: u64) -> Option<H256>` accessor that reads only the `States` entry and pulls the specific index would eliminate the overhead without much extra complexity.

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


#[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
}
}
Loading
Loading