From a263600f1c0eb14bca8ca2f63bc6e1ad0aca4170 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:21:15 +0100 Subject: [PATCH] feat(anvil): extend Content-Type support on Beacon API - Support "application/octet-stream" and "application/json" content types - Implemented `must_be_ssz` helper to determine if the Accept header prefers SSZ encoding. - Updated `handle_get_blobs` to return SSZ-encoded blobs when requested. - Enhanced tests to verify both JSON and SSZ responses for blob sidecars. - Added accept header assertions in tests to ensure correct content type selection. --- Cargo.lock | 22 +++- Cargo.toml | 1 + crates/anvil/Cargo.toml | 1 + crates/anvil/src/server/beacon_handler.rs | 131 +++++++++++++++++++--- crates/anvil/tests/it/beacon_api.rs | 48 +++++++- 5 files changed, 181 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f622b2381ec0..76bbf0cce34c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,7 +228,7 @@ dependencies = [ "c-kzg", "derive_more", "either", - "ethereum_ssz", + "ethereum_ssz 0.9.1", "ethereum_ssz_derive", "serde", "serde_with", @@ -618,7 +618,7 @@ dependencies = [ "alloy-rlp", "alloy-serde", "derive_more", - "ethereum_ssz", + "ethereum_ssz 0.9.1", "ethereum_ssz_derive", "jsonwebtoken", "rand 0.8.5", @@ -1140,6 +1140,7 @@ dependencies = [ "clap", "clap_complete", "ctrlc", + "ethereum_ssz 0.10.0", "eyre", "fdlimit", "flate2", @@ -4030,6 +4031,21 @@ dependencies = [ "typenum", ] +[[package]] +name = "ethereum_ssz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8cd8c4f47dfb947dbfe3cdf2945ae1da808dbedc592668658e827a12659ba1" +dependencies = [ + "alloy-primitives", + "ethereum_serde_utils", + "itertools 0.13.0", + "serde", + "serde_derive", + "smallvec", + "typenum", +] + [[package]] name = "ethereum_ssz_derive" version = "0.9.1" @@ -7215,7 +7231,7 @@ dependencies = [ "alloy-rpc-types-engine", "alloy-serde", "derive_more", - "ethereum_ssz", + "ethereum_ssz 0.9.1", "ethereum_ssz_derive", "op-alloy-consensus", "serde", diff --git a/Cargo.toml b/Cargo.toml index a3a670e8467b9..ada251e92296a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/crates/anvil/Cargo.toml b/crates/anvil/Cargo.toml index 134f836c81f6e..46f2efead395c 100644 --- a/crates/anvil/Cargo.toml +++ b/crates/anvil/Cargo.toml @@ -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 = [ diff --git a/crates/anvil/src/server/beacon_handler.rs b/crates/anvil/src/server/beacon_handler.rs index 75aca4b2c16df..81dbf1229fe35 100644 --- a/crates/anvil/src/server/beacon_handler.rs +++ b/crates/anvil/src/server/beacon_handler.rs @@ -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 { + 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(',') { + 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::().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. @@ -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, Path(block_id): Path, Query(versioned_hashes): Query>, @@ -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(), } @@ -67,17 +114,67 @@ pub async fn handle_get_blobs( /// GET /eth/v1/beacon/genesis pub async fn handle_get_genesis(State(api): State) -> 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 + ); + } + } +} diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index abcd2986724c5..0a9b857433ada 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -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}; @@ -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() { @@ -107,9 +108,13 @@ 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); @@ -117,6 +122,45 @@ async fn test_beacon_api_get_blobs() { // 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::::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={}",