Skip to content

Commit 3ba035f

Browse files
authored
feat(anvil): extend Content-Type support on Beacon API (#12611)
- 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.
1 parent 30aece1 commit 3ba035f

File tree

5 files changed

+181
-22
lines changed

5 files changed

+181
-22
lines changed

Cargo.lock

Lines changed: 19 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ jiff = { version = "0.2", default-features = false, features = [
382382
heck = "0.5"
383383
uuid = "1.18.1"
384384
flate2 = "1.1"
385+
ethereum_ssz = "0.10"
385386

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

crates/anvil/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ tempfile.workspace = true
9595
itertools.workspace = true
9696
rand_08.workspace = true
9797
eyre.workspace = true
98+
ethereum_ssz.workspace = true
9899

99100
# cli
100101
clap = { version = "4", features = [

crates/anvil/src/server/beacon_handler.rs

Lines changed: 114 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,50 @@ use alloy_rpc_types_beacon::{
99
use axum::{
1010
Json,
1111
extract::{Path, Query, State},
12+
http::HeaderMap,
1213
response::{IntoResponse, Response},
1314
};
14-
use hyper::StatusCode;
15+
use ssz::Encode;
1516
use std::{collections::HashMap, str::FromStr as _};
1617

18+
/// Helper function to determine if the Accept header indicates a preference for SSZ (octet-stream)
19+
/// over JSON.
20+
pub fn must_be_ssz(headers: &HeaderMap) -> bool {
21+
headers
22+
.get(axum::http::header::ACCEPT)
23+
.and_then(|v| v.to_str().ok())
24+
.map(|accept_str| {
25+
let mut octet_stream_q = 0.0;
26+
let mut json_q = 0.0;
27+
28+
// Parse each media type in the Accept header
29+
for media_type in accept_str.split(',') {
30+
let media_type = media_type.trim();
31+
let quality = media_type
32+
.split(';')
33+
.find_map(|param| {
34+
let param = param.trim();
35+
if let Some(q) = param.strip_prefix("q=") {
36+
q.parse::<f32>().ok()
37+
} else {
38+
None
39+
}
40+
})
41+
.unwrap_or(1.0); // Default quality factor is 1.0
42+
43+
if media_type.starts_with("application/octet-stream") {
44+
octet_stream_q = quality;
45+
} else if media_type.starts_with("application/json") {
46+
json_q = quality;
47+
}
48+
}
49+
50+
// Prefer octet-stream if it has higher quality factor
51+
octet_stream_q > json_q
52+
})
53+
.unwrap_or(false)
54+
}
55+
1756
/// Handles incoming Beacon API requests for blob sidecars
1857
///
1958
/// This endpoint is deprecated. Use `GET /eth/v1/beacon/blobs/{block_id}` instead.
@@ -32,6 +71,7 @@ pub async fn handle_get_blob_sidecars(
3271
///
3372
/// GET /eth/v1/beacon/blobs/{block_id}
3473
pub async fn handle_get_blobs(
74+
headers: HeaderMap,
3575
State(api): State<EthApi>,
3676
Path(block_id): Path<String>,
3777
Query(versioned_hashes): Query<HashMap<String, String>>,
@@ -50,11 +90,18 @@ pub async fn handle_get_blobs(
5090

5191
// Get the blob sidecars using existing EthApi logic
5292
match api.anvil_get_blobs_by_block_id(block_id, versioned_hashes) {
53-
Ok(Some(blobs)) => (
54-
StatusCode::OK,
55-
Json(GetBlobsResponse { execution_optimistic: false, finalized: false, data: blobs }),
56-
)
57-
.into_response(),
93+
Ok(Some(blobs)) => {
94+
if must_be_ssz(&headers) {
95+
blobs.as_ssz_bytes().into_response()
96+
} else {
97+
Json(GetBlobsResponse {
98+
execution_optimistic: false,
99+
finalized: false,
100+
data: blobs,
101+
})
102+
.into_response()
103+
}
104+
}
58105
Ok(None) => BeaconError::block_not_found().into_response(),
59106
Err(_) => BeaconError::internal_error().into_response(),
60107
}
@@ -67,17 +114,67 @@ pub async fn handle_get_blobs(
67114
/// GET /eth/v1/beacon/genesis
68115
pub async fn handle_get_genesis(State(api): State<EthApi>) -> Response {
69116
match api.anvil_get_genesis_time() {
70-
Ok(genesis_time) => (
71-
StatusCode::OK,
72-
Json(GenesisResponse {
73-
data: GenesisData {
74-
genesis_time,
75-
genesis_validators_root: B256::ZERO,
76-
genesis_fork_version: B32::ZERO,
77-
},
78-
}),
79-
)
80-
.into_response(),
117+
Ok(genesis_time) => Json(GenesisResponse {
118+
data: GenesisData {
119+
genesis_time,
120+
genesis_validators_root: B256::ZERO,
121+
genesis_fork_version: B32::ZERO,
122+
},
123+
})
124+
.into_response(),
81125
Err(_) => BeaconError::internal_error().into_response(),
82126
}
83127
}
128+
#[cfg(test)]
129+
mod tests {
130+
use super::*;
131+
use axum::http::HeaderValue;
132+
133+
fn header_map_with_accept(accept: &str) -> HeaderMap {
134+
let mut headers = HeaderMap::new();
135+
headers.insert(axum::http::header::ACCEPT, HeaderValue::from_str(accept).unwrap());
136+
headers
137+
}
138+
139+
#[test]
140+
fn test_must_be_ssz() {
141+
let test_cases = vec![
142+
(None, false, "no Accept header"),
143+
(Some("application/json"), false, "JSON only"),
144+
(Some("application/octet-stream"), true, "octet-stream only"),
145+
(Some("application/octet-stream;q=1.0,application/json;q=0.9"), true, "SSZ preferred"),
146+
(
147+
Some("application/json;q=1.0,application/octet-stream;q=0.9"),
148+
false,
149+
"JSON preferred",
150+
),
151+
(Some("application/octet-stream;q=0.5,application/json;q=0.5"), false, "equal quality"),
152+
(
153+
Some("text/html;q=0.9, application/octet-stream;q=1.0, application/json;q=0.8"),
154+
true,
155+
"multiple types",
156+
),
157+
(
158+
Some("application/octet-stream ; q=1.0 , application/json ; q=0.9"),
159+
true,
160+
"whitespace handling",
161+
),
162+
(Some("application/octet-stream, application/json;q=0.9"), true, "default quality"),
163+
];
164+
165+
for (accept_header, expected, description) in test_cases {
166+
let headers = match accept_header {
167+
None => HeaderMap::new(),
168+
Some(header) => header_map_with_accept(header),
169+
};
170+
assert_eq!(
171+
must_be_ssz(&headers),
172+
expected,
173+
"Test case '{}' failed: expected {}, got {}",
174+
description,
175+
expected,
176+
!expected
177+
);
178+
}
179+
}
180+
}

crates/anvil/tests/it/beacon_api.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::utils::http_provider;
2-
use alloy_consensus::{SidecarBuilder, SimpleCoder, Transaction};
2+
use alloy_consensus::{Blob, SidecarBuilder, SimpleCoder, Transaction};
33
use alloy_hardforks::EthereumHardfork;
44
use alloy_network::{TransactionBuilder, TransactionBuilder4844};
55
use alloy_primitives::{B256, FixedBytes, U256, b256};
@@ -8,6 +8,7 @@ use alloy_rpc_types::TransactionRequest;
88
use alloy_rpc_types_beacon::{genesis::GenesisResponse, sidecar::GetBlobsResponse};
99
use alloy_serde::WithOtherFields;
1010
use anvil::{NodeConfig, spawn};
11+
use ssz::Decode;
1112

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

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

111117
let blobs_response: GetBlobsResponse = response.json().await.unwrap();
112-
113118
// Verify response structure
114119
assert!(!blobs_response.execution_optimistic);
115120
assert!(!blobs_response.finalized);
116121

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

125+
// Test response with SSZ encoding
126+
let url = format!("{}/eth/v1/beacon/blobs/{}", handle.http_endpoint(), block_number);
127+
let response = client
128+
.get(&url)
129+
.header(axum::http::header::ACCEPT, "application/octet-stream")
130+
.send()
131+
.await
132+
.unwrap();
133+
assert_eq!(response.status(), reqwest::StatusCode::OK);
134+
assert_eq!(
135+
response.headers().get("content-type").and_then(|h| h.to_str().ok()),
136+
Some("application/octet-stream"),
137+
"Expected application/octet-stream content-type header"
138+
);
139+
140+
let body_bytes = response.bytes().await.unwrap();
141+
142+
// Decode the SSZ-encoded blobs in a spawned thread with larger stack to handle recursion
143+
let decoded_blobs = std::thread::Builder::new()
144+
.stack_size(8 * 1024 * 1024) // 8MB stack for SSZ decoding of large blobs
145+
.spawn(move || Vec::<Blob>::from_ssz_bytes(&body_bytes))
146+
.expect("Failed to spawn decode thread")
147+
.join()
148+
.expect("Decode thread panicked")
149+
.expect("Failed to decode SSZ-encoded blobs");
150+
151+
// Verify we got exactly 3 blobs
152+
assert_eq!(
153+
decoded_blobs.len(),
154+
3,
155+
"Expected 3 blobs from SSZ-encoded response, got {}",
156+
decoded_blobs.len()
157+
);
158+
159+
// Verify the decoded blobs match the JSON response blobs
160+
for (i, (decoded, json)) in decoded_blobs.iter().zip(blobs_response.data.iter()).enumerate() {
161+
assert_eq!(decoded, json, "Blob {i} mismatch between SSZ and JSON responses");
162+
}
163+
120164
// Test filtering with versioned_hashes query parameter - single hash
121165
let url = format!(
122166
"{}/eth/v1/beacon/blobs/{}?versioned_hashes={}",

0 commit comments

Comments
 (0)