Skip to content
Merged
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
22 changes: 19 additions & 3 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ jiff = { version = "0.2", default-features = false, features = [
heck = "0.5"
uuid = "1.18.1"
flate2 = "1.1"
ethereum_ssz = "0.10"

## Pinned dependencies. Enabled for the workspace in crates/test-utils.

Expand Down
1 change: 1 addition & 0 deletions crates/anvil/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ tempfile.workspace = true
itertools.workspace = true
rand_08.workspace = true
eyre.workspace = true
ethereum_ssz.workspace = true

# cli
clap = { version = "4", features = [
Expand Down
131 changes: 114 additions & 17 deletions crates/anvil/src/server/beacon_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,50 @@ use alloy_rpc_types_beacon::{
use axum::{
Json,
extract::{Path, Query, State},
http::HeaderMap,
response::{IntoResponse, Response},
};
use hyper::StatusCode;
use ssz::Encode;
use std::{collections::HashMap, str::FromStr as _};

/// Helper function to determine if the Accept header indicates a preference for SSZ (octet-stream)
/// over JSON.
pub fn must_be_ssz(headers: &HeaderMap) -> bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: shouldn't be public

Copy link
Contributor Author

@mablr mablr Nov 19, 2025

Choose a reason for hiding this comment

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

This was merged too fast 😅

I may fix it as a follow-up. Btw Anvil's server module can be split/reorganized for clarity:

  • crates/anvil/src/server/mod.rs
  • crates/anvil/src/server/beacon/
  • crates/anvil/src/server/rpc/

I can wrap it all in one PR.

Copy link
Collaborator

Choose a reason for hiding this comment

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

thank you! yeah not a prio, can include in future PR

headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|accept_str| {
let mut octet_stream_q = 0.0;
let mut json_q = 0.0;

// Parse each media type in the Accept header
for media_type in accept_str.split(',') {
Copy link
Collaborator

Choose a reason for hiding this comment

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

should we worry if same media type present multiple times? (I think that's technically allowed) - probably not an issue as we'll consider the last one

let media_type = media_type.trim();
let quality = media_type
.split(';')
.find_map(|param| {
let param = param.trim();
if let Some(q) = param.strip_prefix("q=") {
q.parse::<f32>().ok()
} else {
None
}
})
.unwrap_or(1.0); // Default quality factor is 1.0

if media_type.starts_with("application/octet-stream") {
octet_stream_q = quality;
} else if media_type.starts_with("application/json") {
json_q = quality;
}
}

// Prefer octet-stream if it has higher quality factor
octet_stream_q > json_q
})
.unwrap_or(false)
}

/// Handles incoming Beacon API requests for blob sidecars
///
/// This endpoint is deprecated. Use `GET /eth/v1/beacon/blobs/{block_id}` instead.
Expand All @@ -32,6 +71,7 @@ pub async fn handle_get_blob_sidecars(
///
/// GET /eth/v1/beacon/blobs/{block_id}
pub async fn handle_get_blobs(
headers: HeaderMap,
State(api): State<EthApi>,
Path(block_id): Path<String>,
Query(versioned_hashes): Query<HashMap<String, String>>,
Expand All @@ -50,11 +90,18 @@ pub async fn handle_get_blobs(

// Get the blob sidecars using existing EthApi logic
match api.anvil_get_blobs_by_block_id(block_id, versioned_hashes) {
Ok(Some(blobs)) => (
StatusCode::OK,
Json(GetBlobsResponse { execution_optimistic: false, finalized: false, data: blobs }),
)
.into_response(),
Ok(Some(blobs)) => {
if must_be_ssz(&headers) {
blobs.as_ssz_bytes().into_response()
} else {
Json(GetBlobsResponse {
execution_optimistic: false,
finalized: false,
data: blobs,
})
.into_response()
}
}
Ok(None) => BeaconError::block_not_found().into_response(),
Err(_) => BeaconError::internal_error().into_response(),
}
Expand All @@ -67,17 +114,67 @@ pub async fn handle_get_blobs(
/// GET /eth/v1/beacon/genesis
pub async fn handle_get_genesis(State(api): State<EthApi>) -> Response {
match api.anvil_get_genesis_time() {
Ok(genesis_time) => (
StatusCode::OK,
Json(GenesisResponse {
data: GenesisData {
genesis_time,
genesis_validators_root: B256::ZERO,
genesis_fork_version: B32::ZERO,
},
}),
)
.into_response(),
Ok(genesis_time) => Json(GenesisResponse {
data: GenesisData {
genesis_time,
genesis_validators_root: B256::ZERO,
genesis_fork_version: B32::ZERO,
},
})
.into_response(),
Err(_) => BeaconError::internal_error().into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;

fn header_map_with_accept(accept: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(axum::http::header::ACCEPT, HeaderValue::from_str(accept).unwrap());
headers
}

#[test]
fn test_must_be_ssz() {
let test_cases = vec![
(None, false, "no Accept header"),
(Some("application/json"), false, "JSON only"),
(Some("application/octet-stream"), true, "octet-stream only"),
(Some("application/octet-stream;q=1.0,application/json;q=0.9"), true, "SSZ preferred"),
(
Some("application/json;q=1.0,application/octet-stream;q=0.9"),
false,
"JSON preferred",
),
(Some("application/octet-stream;q=0.5,application/json;q=0.5"), false, "equal quality"),
(
Some("text/html;q=0.9, application/octet-stream;q=1.0, application/json;q=0.8"),
true,
"multiple types",
),
(
Some("application/octet-stream ; q=1.0 , application/json ; q=0.9"),
true,
"whitespace handling",
),
(Some("application/octet-stream, application/json;q=0.9"), true, "default quality"),
];

for (accept_header, expected, description) in test_cases {
let headers = match accept_header {
None => HeaderMap::new(),
Some(header) => header_map_with_accept(header),
};
assert_eq!(
must_be_ssz(&headers),
expected,
"Test case '{}' failed: expected {}, got {}",
description,
expected,
!expected
);
}
}
}
48 changes: 46 additions & 2 deletions crates/anvil/tests/it/beacon_api.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::utils::http_provider;
use alloy_consensus::{SidecarBuilder, SimpleCoder, Transaction};
use alloy_consensus::{Blob, SidecarBuilder, SimpleCoder, Transaction};
use alloy_hardforks::EthereumHardfork;
use alloy_network::{TransactionBuilder, TransactionBuilder4844};
use alloy_primitives::{B256, FixedBytes, U256, b256};
Expand All @@ -8,6 +8,7 @@ use alloy_rpc_types::TransactionRequest;
use alloy_rpc_types_beacon::{genesis::GenesisResponse, sidecar::GetBlobsResponse};
use alloy_serde::WithOtherFields;
use anvil::{NodeConfig, spawn};
use ssz::Decode;

#[tokio::test(flavor = "multi_thread")]
async fn test_beacon_api_get_blob_sidecars() {
Expand Down Expand Up @@ -107,16 +108,59 @@ async fn test_beacon_api_get_blobs() {

let response = client.get(&url).send().await.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
response.headers().get("content-type").and_then(|h| h.to_str().ok()),
Some("application/json"),
"Expected application/json content-type header"
);

let blobs_response: GetBlobsResponse = response.json().await.unwrap();

// Verify response structure
assert!(!blobs_response.execution_optimistic);
assert!(!blobs_response.finalized);

// Verify we have blob data from all transactions
assert_eq!(blobs_response.data.len(), 3, "Expected 3 blobs from 3 transactions");

// Test response with SSZ encoding
let url = format!("{}/eth/v1/beacon/blobs/{}", handle.http_endpoint(), block_number);
let response = client
.get(&url)
.header(axum::http::header::ACCEPT, "application/octet-stream")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
response.headers().get("content-type").and_then(|h| h.to_str().ok()),
Some("application/octet-stream"),
"Expected application/octet-stream content-type header"
);

let body_bytes = response.bytes().await.unwrap();

// Decode the SSZ-encoded blobs in a spawned thread with larger stack to handle recursion
let decoded_blobs = std::thread::Builder::new()
.stack_size(8 * 1024 * 1024) // 8MB stack for SSZ decoding of large blobs
.spawn(move || Vec::<Blob>::from_ssz_bytes(&body_bytes))
.expect("Failed to spawn decode thread")
.join()
.expect("Decode thread panicked")
.expect("Failed to decode SSZ-encoded blobs");

// Verify we got exactly 3 blobs
assert_eq!(
decoded_blobs.len(),
3,
"Expected 3 blobs from SSZ-encoded response, got {}",
decoded_blobs.len()
);

// Verify the decoded blobs match the JSON response blobs
for (i, (decoded, json)) in decoded_blobs.iter().zip(blobs_response.data.iter()).enumerate() {
assert_eq!(decoded, json, "Blob {i} mismatch between SSZ and JSON responses");
}

// Test filtering with versioned_hashes query parameter - single hash
let url = format!(
"{}/eth/v1/beacon/blobs/{}?versioned_hashes={}",
Expand Down
Loading