From a6a62619b324012705ce68688d1093df1adec831 Mon Sep 17 00:00:00 2001 From: geoknee Date: Tue, 16 Dec 2025 14:22:49 +0000 Subject: [PATCH 01/22] Add httpmock-based test for OnlineBeaconClient --- Cargo.lock | 120 ++++++++++++++++++ crates/providers/providers-alloy/Cargo.toml | 2 + .../providers-alloy/src/beacon_client.rs | 44 +++++++ 3 files changed, 166 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 160d6d3e23..933d485eff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1341,6 +1341,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "155a5a185e42c6b77ac7b88a15143d930a9e9727a5b7b77eed417404ab15c247" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -1395,6 +1405,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-object-pool" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f" +dependencies = [ + "async-lock", + "event-listener", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -3695,6 +3715,30 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.5.0" @@ -3847,6 +3891,40 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpmock" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "511f510e9b1888d67f10bab4397f8b019d2a9b249a2c10acbce2d705b1b32e26" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-trait", + "base64", + "bytes", + "crossbeam-utils", + "form_urlencoded", + "futures-timer", + "futures-util", + "headers", + "http", + "http-body-util", + "hyper", + "hyper-util", + "path-tree", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "stringmetrics", + "tabwriter", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", +] + [[package]] name = "hyper" version = "1.8.1" @@ -5324,6 +5402,7 @@ dependencies = [ "async-trait", "c-kzg", "http-body-util", + "httpmock", "kona-derive", "kona-genesis", "kona-macros", @@ -5334,6 +5413,7 @@ dependencies = [ "op-alloy-network", "reqwest", "serde", + "serde_json", "thiserror 2.0.17", "tokio", "tower 0.5.2", @@ -7320,6 +7400,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "path-tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136" +dependencies = [ + "smallvec", +] + [[package]] name = "pem" version = "3.0.6" @@ -10424,6 +10513,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -10585,6 +10684,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simple_asn1" version = "0.6.3" @@ -10730,6 +10835,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" +[[package]] +name = "stringmetrics" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" + [[package]] name = "strsim" version = "0.11.1" @@ -10898,6 +11009,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width", +] + [[package]] name = "tagptr" version = "0.2.0" diff --git a/crates/providers/providers-alloy/Cargo.toml b/crates/providers/providers-alloy/Cargo.toml index 48139b18bd..a522fe1633 100644 --- a/crates/providers/providers-alloy/Cargo.toml +++ b/crates/providers/providers-alloy/Cargo.toml @@ -58,3 +58,5 @@ metrics = [ "dep:metrics", "kona-derive/metrics" ] [dev-dependencies] tokio.workspace = true +httpmock = "0.8.2" +serde_json.workspace = true diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 0708a22275..c8e3edb941 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -229,3 +229,47 @@ impl BeaconClient for OnlineBeaconClient { result } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Blob; + use alloy_primitives::FixedBytes; + use httpmock::prelude::*; + use serde_json::json; + + #[tokio::test] + async fn test_filtered_beacon_blobs() { + let slot = 987654321; + let slot_string = slot.to_string(); + let blob_data: Blob = FixedBytes::repeat_byte(1); + let repeated_blob_data: Vec = + vec![blob_data.clone(), blob_data.clone(), blob_data.clone()]; + let repeated_blob_response = json!({ + "execution_optimistic": false, + "finalized": false, + "data": repeated_blob_data + }); + + let server = MockServer::start(); + let blobs_mock = server.mock(|when, then| { + when.method(GET).path(format!("/eth/v1/beacon/blobs/{}", slot_string)); + then.status(200).json_body(repeated_blob_response); + }); + let client = OnlineBeaconClient::new_http(server.base_url()); + let response = client + .filtered_beacon_blobs( + slot, + &[ + IndexedBlobHash { index: 0, hash: FixedBytes::repeat_byte(11) }, + IndexedBlobHash { index: 1, hash: FixedBytes::repeat_byte(11) }, + ], + ) // ask for blobs 0 and 1, the hashes are not important / not inspected + .await + .unwrap(); + blobs_mock.assert(); + assert_eq!(response.len(), 2); // We expect to filter two of the three blobs, by the indices passed above. + assert_eq!(response[0].blob, Box::new(blob_data.clone())); + assert_eq!(response[1].blob, Box::new(blob_data.clone())); + } +} From 8f71b93fc514c8340660659afc5b824f463be1ae Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 17 Dec 2025 11:46:48 +0000 Subject: [PATCH 02/22] WIP --- .../providers-alloy/src/beacon_client.rs | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index c8e3edb941..1e5d504e0c 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -3,9 +3,10 @@ #[cfg(feature = "metrics")] use crate::Metrics; use crate::blobs::BoxedBlobWithIndex; -use alloy_eips::eip4844::IndexedBlobHash; +use alloy_eips::eip4844::{IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash}; use alloy_rpc_types_beacon::sidecar::{BeaconBlobBundle, GetBlobsResponse}; use async_trait::async_trait; +use c_kzg::Blob; use reqwest::Client; use std::{boxed::Box, format, string::String, vec::Vec}; @@ -121,51 +122,67 @@ impl OnlineBeaconClient { self } + // Fetches blobs from the /eth/v1/beacon/blobs endpoint using the provided (versioned)blob hashes. + // Blobs are validated against the supplied versioned hashes + // and returned in the same order as the input. async fn filtered_beacon_blobs( &self, slot: u64, blob_hashes: &[IndexedBlobHash], ) -> Result, reqwest::Error> { let blob_indexes = blob_hashes.iter().map(|blob| blob.index).collect::>(); - + let params = blob_hashes.iter().map(|blob| blob.hash.to_string()).collect::>(); + let kzg_settings = EnvKzgSettings::Default; Ok( match self .inner .get(format!("{}/{}/{}", self.base, BLOBS_METHOD_PREFIX, slot)) + .query(&[("versioned_hashes", ¶ms.join(",").as_str())]) .send() .await { Ok(response) if response.status().is_success() => { let bundle = response.json::().await?; - bundle - .data - .into_iter() - .enumerate() - .filter_map(|(index, blob)| { - let index = index as u64; - blob_indexes - .contains(&index) - .then_some(BoxedBlobWithIndex { index, blob: Box::new(blob) }) + // Map blobs into versioned hashes for validation and + // matching against input: + let mut response_blob_hashes = bundle.data.iter().map(|blob| { + let kzg_blob = Blob::new(blob.0); + let commitment = kzg_settings + .get() + .blob_to_kzg_commitment(&kzg_blob) + .map(|blob| blob.to_bytes()) + .unwrap(); + kzg_to_versioned_hash(commitment.as_slice()) + }); + + // Map the input into the output, finding the blob from the response + // whose hash matches the input: + blob_hashes + .iter() + .map(|blob_hash| { + let idx = response_blob_hashes + .position(|response_blob_hash| response_blob_hash == blob_hash.hash) + .unwrap(); // TODO handle this error "blob for blob hash not found" + BoxedBlobWithIndex { + blob: Box::new(*bundle.data.get(idx).unwrap()), + index: blob_hash.index, + } }) .collect::>() } + Ok(response) => { + panic!( + "got a response, but not success, {}, {}", + response.status(), + response.text().await.unwrap() + ) + } // If the blobs endpoint fails, try the deprecated sidecars endpoint. CL Clients // only support the blobs endpoint from Fusaka (Fulu) onwards. - _ => self - .inner - .get(format!("{}/{}/{}", self.base, SIDECARS_METHOD_PREFIX_DEPRECATED, slot)) - .send() - .await? - .json::() - .await? - .into_iter() - .filter_map(|blob| { - blob_indexes - .contains(&blob.index) - .then_some(BoxedBlobWithIndex { index: blob.index, blob: blob.blob }) - }) - .collect::>(), + Err(err) => { + panic!("Failed to fetch blobs from the blobs endpoint, {}", err) + } }, ) } @@ -253,15 +270,18 @@ mod tests { let server = MockServer::start(); let blobs_mock = server.mock(|when, then| { - when.method(GET).path(format!("/eth/v1/beacon/blobs/{}", slot_string)); + when.method(GET) + .path(format!("/eth/v1/beacon/blobs/{}", slot_string)) + .query_param_exists("versioned_hashes"); then.status(200).json_body(repeated_blob_response); }); + let client = OnlineBeaconClient::new_http(server.base_url()); let response = client .filtered_beacon_blobs( slot, &[ - IndexedBlobHash { index: 0, hash: FixedBytes::repeat_byte(11) }, + IndexedBlobHash { index: 0, hash: FixedBytes::repeat_byte(00) }, IndexedBlobHash { index: 1, hash: FixedBytes::repeat_byte(11) }, ], ) // ask for blobs 0 and 1, the hashes are not important / not inspected From ef7987996e2732f3bdbe223dd88f314e0d1738c4 Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 17 Dec 2025 13:54:18 +0000 Subject: [PATCH 03/22] WIP --- .../providers-alloy/src/beacon_client.rs | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 1e5d504e0c..aeeac848c5 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -4,6 +4,7 @@ use crate::Metrics; use crate::blobs::BoxedBlobWithIndex; use alloy_eips::eip4844::{IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash}; +use alloy_primitives::{B256, FixedBytes}; use alloy_rpc_types_beacon::sidecar::{BeaconBlobBundle, GetBlobsResponse}; use async_trait::async_trait; use c_kzg::Blob; @@ -89,6 +90,15 @@ pub trait BeaconClient { ) -> Result, Self::Error>; } +/// blob_versioned_hash computes the versioned hash of a blob. +fn blob_versioned_hash(blob: &FixedBytes<131072>) -> B256 { + let kzg_settings = EnvKzgSettings::Default; + let kzg_blob = Blob::new(blob.0); + let commitment = + kzg_settings.get().blob_to_kzg_commitment(&kzg_blob).map(|blob| blob.to_bytes()).unwrap(); + kzg_to_versioned_hash(commitment.as_slice()) +} + /// An online implementation of the [BeaconClient] trait. #[derive(Debug, Clone)] pub struct OnlineBeaconClient { @@ -122,9 +132,10 @@ impl OnlineBeaconClient { self } - // Fetches blobs from the /eth/v1/beacon/blobs endpoint using the provided (versioned)blob hashes. - // Blobs are validated against the supplied versioned hashes - // and returned in the same order as the input. + /// Fetches only the blobs corresponding to the provided (versioned) blob hashes + /// from the beacon /eth/v1/beacon/blobs endpoint. + /// Blobs are validated against the supplied versioned hashes + /// and returned in the same order as the input. async fn filtered_beacon_blobs( &self, slot: u64, @@ -132,7 +143,7 @@ impl OnlineBeaconClient { ) -> Result, reqwest::Error> { let blob_indexes = blob_hashes.iter().map(|blob| blob.index).collect::>(); let params = blob_hashes.iter().map(|blob| blob.hash.to_string()).collect::>(); - let kzg_settings = EnvKzgSettings::Default; + Ok( match self .inner @@ -146,15 +157,7 @@ impl OnlineBeaconClient { // Map blobs into versioned hashes for validation and // matching against input: - let mut response_blob_hashes = bundle.data.iter().map(|blob| { - let kzg_blob = Blob::new(blob.0); - let commitment = kzg_settings - .get() - .blob_to_kzg_commitment(&kzg_blob) - .map(|blob| blob.to_bytes()) - .unwrap(); - kzg_to_versioned_hash(commitment.as_slice()) - }); + let mut response_blob_hashes = bundle.data.iter().map(blob_versioned_hash); // Map the input into the output, finding the blob from the response // whose hash matches the input: @@ -251,28 +254,45 @@ impl BeaconClient for OnlineBeaconClient { mod tests { use super::*; use alloy_consensus::Blob; - use alloy_primitives::FixedBytes; + use alloy_primitives::{FixedBytes, hex::FromHex}; use httpmock::prelude::*; use serde_json::json; + #[test] + fn test_blob_versioned_hash() { + let input: Blob = FixedBytes::repeat_byte(1); + let want: FixedBytes<32> = FixedBytes::from_hex( + "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04", + ) + .unwrap(); + assert_eq!(want, blob_versioned_hash(&input)); + } + #[tokio::test] async fn test_filtered_beacon_blobs() { let slot = 987654321; let slot_string = slot.to_string(); let blob_data: Blob = FixedBytes::repeat_byte(1); - let repeated_blob_data: Vec = - vec![blob_data.clone(), blob_data.clone(), blob_data.clone()]; + let repeated_blob_data: Vec = vec![blob_data.clone(), blob_data.clone()]; let repeated_blob_response = json!({ "execution_optimistic": false, "finalized": false, "data": repeated_blob_data }); + // The following hash corresponds to the all 1s blob (see test above): + let blob_hash_of_interest_hex = + "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04"; + let blob_hash_of_interest = FixedBytes::from_hex(blob_hash_of_interest_hex).unwrap(); + let required_query_param = + format!("{},{}", blob_hash_of_interest_hex, blob_hash_of_interest_hex); + + // This server mocks a single, specific query on a beacon node, let server = MockServer::start(); let blobs_mock = server.mock(|when, then| { when.method(GET) .path(format!("/eth/v1/beacon/blobs/{}", slot_string)) - .query_param_exists("versioned_hashes"); + .query_param("versioned_hashes", required_query_param); then.status(200).json_body(repeated_blob_response); }); @@ -281,15 +301,17 @@ mod tests { .filtered_beacon_blobs( slot, &[ - IndexedBlobHash { index: 0, hash: FixedBytes::repeat_byte(00) }, - IndexedBlobHash { index: 1, hash: FixedBytes::repeat_byte(11) }, + IndexedBlobHash { index: 0, hash: blob_hash_of_interest }, + IndexedBlobHash { index: 2, hash: blob_hash_of_interest }, ], - ) // ask for blobs 0 and 1, the hashes are not important / not inspected + ) // ask for blobs 0 and 2, which happen to have identical data and hashes .await .unwrap(); blobs_mock.assert(); - assert_eq!(response.len(), 2); // We expect to filter two of the three blobs, by the indices passed above. - assert_eq!(response[0].blob, Box::new(blob_data.clone())); - assert_eq!(response[1].blob, Box::new(blob_data.clone())); + let want: Vec = vec![ + BoxedBlobWithIndex { index: 0, blob: Box::new(blob_data.clone()) }, + BoxedBlobWithIndex { index: 2, blob: Box::new(blob_data.clone()) }, + ]; + assert_eq!(response, want) } } From e2e19d4cf5cd6c9a412bfe7f08936ed71cb7e9a3 Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 17 Dec 2025 14:49:52 +0000 Subject: [PATCH 04/22] WIP TODO: allow for different kinds of errors to be returned, when we get a response which fails validation TODO: resintate fallback to sidecars endpoint? --- .../providers-alloy/src/beacon_client.rs | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index aeeac848c5..1cd451f345 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -5,11 +5,12 @@ use crate::Metrics; use crate::blobs::BoxedBlobWithIndex; use alloy_eips::eip4844::{IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash}; use alloy_primitives::{B256, FixedBytes}; +use alloy_provider::ReqwestProvider; use alloy_rpc_types_beacon::sidecar::{BeaconBlobBundle, GetBlobsResponse}; use async_trait::async_trait; use c_kzg::Blob; use reqwest::Client; -use std::{boxed::Box, format, string::String, vec::Vec}; +use std::{boxed::Box, format, io, string::String, vec::Vec}; /// The config spec engine api method. const SPEC_METHOD: &str = "eth/v1/config/spec"; @@ -143,51 +144,50 @@ impl OnlineBeaconClient { ) -> Result, reqwest::Error> { let blob_indexes = blob_hashes.iter().map(|blob| blob.index).collect::>(); let params = blob_hashes.iter().map(|blob| blob.hash.to_string()).collect::>(); - - Ok( - match self - .inner - .get(format!("{}/{}/{}", self.base, BLOBS_METHOD_PREFIX, slot)) - .query(&[("versioned_hashes", ¶ms.join(",").as_str())]) - .send() - .await - { - Ok(response) if response.status().is_success() => { - let bundle = response.json::().await?; - - // Map blobs into versioned hashes for validation and - // matching against input: - let mut response_blob_hashes = bundle.data.iter().map(blob_versioned_hash); - - // Map the input into the output, finding the blob from the response - // whose hash matches the input: - blob_hashes - .iter() - .map(|blob_hash| { - let idx = response_blob_hashes - .position(|response_blob_hash| response_blob_hash == blob_hash.hash) - .unwrap(); // TODO handle this error "blob for blob hash not found" - BoxedBlobWithIndex { - blob: Box::new(*bundle.data.get(idx).unwrap()), - index: blob_hash.index, - } + match self + .inner + .get(format!("{}/{}/{}", self.base, BLOBS_METHOD_PREFIX, slot)) + .query(&[("versioned_hashes", ¶ms.join(",").as_str())]) + .send() + .await + { + Ok(response) if response.status().is_success() => { + let bundle = response.json::().await?; + + // Map blobs into versioned hashes for validation and + // matching against input: + let response_blob_hashes = + bundle.data.iter().map(blob_versioned_hash).collect::>(); + + // Map the input into the output, finding the blob from the response + // whose hash matches the input: + blob_hashes + .iter() + .map(|blob_hash| -> Result { + let idx = response_blob_hashes + .iter() + .position(|response_blob_hash| *response_blob_hash == blob_hash.hash) + .unwrap(); + Ok(BoxedBlobWithIndex { + blob: Box::new(*bundle.data.get(idx).unwrap()), + index: blob_hash.index, }) - .collect::>() - } - Ok(response) => { - panic!( - "got a response, but not success, {}, {}", - response.status(), - response.text().await.unwrap() - ) - } - // If the blobs endpoint fails, try the deprecated sidecars endpoint. CL Clients - // only support the blobs endpoint from Fusaka (Fulu) onwards. - Err(err) => { - panic!("Failed to fetch blobs from the blobs endpoint, {}", err) - } - }, - ) + }) + .collect::, reqwest::Error>>() + } + Ok(response) => { + panic!( + "got a response, but not success, {}, {}", + response.status(), + response.text().await.unwrap() + ) + } + // If the blobs endpoint fails, try the deprecated sidecars endpoint. CL Clients + // only support the blobs endpoint from Fusaka (Fulu) onwards. + Err(err) => { + panic!("Failed to fetch blobs from the blobs endpoint, {}", err) + } + } } } From 33012470fb971e576916d450d54aa7c01ca336da Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 17 Dec 2025 21:08:50 +0000 Subject: [PATCH 05/22] define custom BeaconClientError --- .../providers-alloy/src/beacon_client.rs | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 1cd451f345..376bc63f92 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -10,7 +10,13 @@ use alloy_rpc_types_beacon::sidecar::{BeaconBlobBundle, GetBlobsResponse}; use async_trait::async_trait; use c_kzg::Blob; use reqwest::Client; -use std::{boxed::Box, format, io, string::String, vec::Vec}; +use std::{ + boxed::Box, + format, + io::{self, Error}, + string::String, + vec::Vec, +}; /// The config spec engine api method. const SPEC_METHOD: &str = "eth/v1/config/spec"; @@ -100,6 +106,17 @@ fn blob_versioned_hash(blob: &FixedBytes<131072>) -> B256 { kzg_to_versioned_hash(commitment.as_slice()) } +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum BeaconClientError { + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + #[error("Blob hash not found in beacon response {0}")] + BlobNotFound(String), +} + /// An online implementation of the [BeaconClient] trait. #[derive(Debug, Clone)] pub struct OnlineBeaconClient { @@ -141,7 +158,7 @@ impl OnlineBeaconClient { &self, slot: u64, blob_hashes: &[IndexedBlobHash], - ) -> Result, reqwest::Error> { + ) -> Result, BeaconClientError> { let blob_indexes = blob_hashes.iter().map(|blob| blob.index).collect::>(); let params = blob_hashes.iter().map(|blob| blob.hash.to_string()).collect::>(); match self @@ -163,17 +180,17 @@ impl OnlineBeaconClient { // whose hash matches the input: blob_hashes .iter() - .map(|blob_hash| -> Result { + .map(|blob_hash| -> Result { let idx = response_blob_hashes .iter() .position(|response_blob_hash| *response_blob_hash == blob_hash.hash) - .unwrap(); + .ok_or(BeaconClientError::BlobNotFound(blob_hash.hash.to_string()))?; Ok(BoxedBlobWithIndex { blob: Box::new(*bundle.data.get(idx).unwrap()), index: blob_hash.index, }) }) - .collect::, reqwest::Error>>() + .collect::, BeaconClientError>>() } Ok(response) => { panic!( @@ -193,7 +210,7 @@ impl OnlineBeaconClient { #[async_trait] impl BeaconClient for OnlineBeaconClient { - type Error = reqwest::Error; + type Error = BeaconClientError; async fn slot_interval(&self) -> Result { kona_macros::inc!(gauge, Metrics::BEACON_CLIENT_REQUESTS, "method" => "spec"); @@ -213,7 +230,7 @@ impl BeaconClient for OnlineBeaconClient { kona_macros::inc!(gauge, Metrics::BEACON_CLIENT_ERRORS, "method" => "spec"); } - result + result.map_err(|err| BeaconClientError::Http(err)) } async fn genesis_time(&self) -> Result { @@ -229,14 +246,14 @@ impl BeaconClient for OnlineBeaconClient { kona_macros::inc!(gauge, Metrics::BEACON_CLIENT_ERRORS, "method" => "genesis"); } - result + result.map_err(|err| BeaconClientError::Http(err)) } async fn filtered_beacon_blobs( &self, slot: u64, blob_hashes: &[IndexedBlobHash], - ) -> Result, Self::Error> { + ) -> Result, BeaconClientError> { kona_macros::inc!(gauge, Metrics::BEACON_CLIENT_REQUESTS, "method" => "blobs"); // Try to get the blobs from the blobs endpoint. From 8b041d5f1b7ae01a6b0ba633c728b617d9400468 Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 17 Dec 2025 21:28:30 +0000 Subject: [PATCH 06/22] Test error handling for incorrect blob responses Make blobs_mock mutable and clone required_query_param in the test. Add a second mock that returns garbage blob data and assert that filtered_beacon_blobs returns an error. Also fix the comment describing the blob hash. --- .../providers-alloy/src/beacon_client.rs | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 376bc63f92..e772a3a6f8 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -297,7 +297,7 @@ mod tests { "data": repeated_blob_data }); - // The following hash corresponds to the all 1s blob (see test above): + // The following hash corresponds to the all 01s blob_data (see test above): let blob_hash_of_interest_hex = "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04"; let blob_hash_of_interest = FixedBytes::from_hex(blob_hash_of_interest_hex).unwrap(); @@ -306,10 +306,10 @@ mod tests { // This server mocks a single, specific query on a beacon node, let server = MockServer::start(); - let blobs_mock = server.mock(|when, then| { + let mut blobs_mock = server.mock(|when, then| { when.method(GET) .path(format!("/eth/v1/beacon/blobs/{}", slot_string)) - .query_param("versioned_hashes", required_query_param); + .query_param("versioned_hashes", required_query_param.clone()); then.status(200).json_body(repeated_blob_response); }); @@ -325,10 +325,38 @@ mod tests { .await .unwrap(); blobs_mock.assert(); + let want: Vec = vec![ BoxedBlobWithIndex { index: 0, blob: Box::new(blob_data.clone()) }, BoxedBlobWithIndex { index: 2, blob: Box::new(blob_data.clone()) }, ]; - assert_eq!(response, want) + assert_eq!(response, want); + + // Replace the mock with one which will provide an incorrect response + blobs_mock.delete(); + let garbage_blob_data: Blob = FixedBytes::repeat_byte(2); + let incorrect_blob_response = json!({ + "execution_optimistic": false, + "finalized": false, + "data": garbage_blob_data + }); + let incorrect_blobs_mock = server.mock(|when, then| { + when.method(GET) + .path(format!("/eth/v1/beacon/blobs/{}", slot_string)) + .query_param("versioned_hashes", required_query_param.clone()); + then.status(200).json_body(incorrect_blob_response); + }); + + client + .filtered_beacon_blobs( + slot, + &[ + IndexedBlobHash { index: 0, hash: blob_hash_of_interest }, + IndexedBlobHash { index: 2, hash: blob_hash_of_interest }, + ], + ) // ask for blobs 0 and 2, which happen to have identical data and hashes + .await + .expect_err("Expected error when mocking an incorrect response from the beacon server"); + incorrect_blobs_mock.assert(); } } From dd92ea22d112e8b0c7197411fe938a066fb623f4 Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 17 Dec 2025 21:46:18 +0000 Subject: [PATCH 07/22] Remove unused imports and deprecated constant Drop unused ReqwestProvider and BeaconBlobBundle imports, remove SIDECARS_METHOD_PREFIX_DEPRECATED, compress std imports, and delete the unused `blob_indexes` local variable. --- .../providers-alloy/src/beacon_client.rs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index e772a3a6f8..f3ee5da20e 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -5,18 +5,11 @@ use crate::Metrics; use crate::blobs::BoxedBlobWithIndex; use alloy_eips::eip4844::{IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash}; use alloy_primitives::{B256, FixedBytes}; -use alloy_provider::ReqwestProvider; -use alloy_rpc_types_beacon::sidecar::{BeaconBlobBundle, GetBlobsResponse}; +use alloy_rpc_types_beacon::sidecar::GetBlobsResponse; use async_trait::async_trait; use c_kzg::Blob; use reqwest::Client; -use std::{ - boxed::Box, - format, - io::{self, Error}, - string::String, - vec::Vec, -}; +use std::{boxed::Box, format, string::String, vec::Vec}; /// The config spec engine api method. const SPEC_METHOD: &str = "eth/v1/config/spec"; @@ -24,9 +17,6 @@ const SPEC_METHOD: &str = "eth/v1/config/spec"; /// The beacon genesis engine api method. const GENESIS_METHOD: &str = "eth/v1/beacon/genesis"; -/// The blob sidecars engine api method prefix. -const SIDECARS_METHOD_PREFIX_DEPRECATED: &str = "eth/v1/beacon/blob_sidecars"; - /// THe blobs engine api method prefix. const BLOBS_METHOD_PREFIX: &str = "eth/v1/beacon/blobs"; @@ -159,7 +149,6 @@ impl OnlineBeaconClient { slot: u64, blob_hashes: &[IndexedBlobHash], ) -> Result, BeaconClientError> { - let blob_indexes = blob_hashes.iter().map(|blob| blob.index).collect::>(); let params = blob_hashes.iter().map(|blob| blob.hash.to_string()).collect::>(); match self .inner From 0b01c59a676394dafb7c541903118fdad2dc4032 Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 17 Dec 2025 23:28:15 +0000 Subject: [PATCH 08/22] Simplify blob fetch by propagating HTTP errors --- .../providers-alloy/src/beacon_client.rs | 63 +++++++------------ 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index f3ee5da20e..69b7a87506 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -150,50 +150,35 @@ impl OnlineBeaconClient { blob_hashes: &[IndexedBlobHash], ) -> Result, BeaconClientError> { let params = blob_hashes.iter().map(|blob| blob.hash.to_string()).collect::>(); - match self + let response = self .inner .get(format!("{}/{}/{}", self.base, BLOBS_METHOD_PREFIX, slot)) .query(&[("versioned_hashes", ¶ms.join(",").as_str())]) .send() - .await - { - Ok(response) if response.status().is_success() => { - let bundle = response.json::().await?; - - // Map blobs into versioned hashes for validation and - // matching against input: - let response_blob_hashes = - bundle.data.iter().map(blob_versioned_hash).collect::>(); - - // Map the input into the output, finding the blob from the response - // whose hash matches the input: - blob_hashes + .await? + .error_for_status()?; + + let bundle = response.json::().await?; + + // Map blobs into versioned hashes for validation and + // matching against input: + let response_blob_hashes = bundle.data.iter().map(blob_versioned_hash).collect::>(); + + // Map the input into the output, finding the blob from the response + // whose hash matches the input: + blob_hashes + .iter() + .map(|blob_hash| -> Result { + let idx = response_blob_hashes .iter() - .map(|blob_hash| -> Result { - let idx = response_blob_hashes - .iter() - .position(|response_blob_hash| *response_blob_hash == blob_hash.hash) - .ok_or(BeaconClientError::BlobNotFound(blob_hash.hash.to_string()))?; - Ok(BoxedBlobWithIndex { - blob: Box::new(*bundle.data.get(idx).unwrap()), - index: blob_hash.index, - }) - }) - .collect::, BeaconClientError>>() - } - Ok(response) => { - panic!( - "got a response, but not success, {}, {}", - response.status(), - response.text().await.unwrap() - ) - } - // If the blobs endpoint fails, try the deprecated sidecars endpoint. CL Clients - // only support the blobs endpoint from Fusaka (Fulu) onwards. - Err(err) => { - panic!("Failed to fetch blobs from the blobs endpoint, {}", err) - } - } + .position(|response_blob_hash| *response_blob_hash == blob_hash.hash) + .ok_or(BeaconClientError::BlobNotFound(blob_hash.hash.to_string()))?; + Ok(BoxedBlobWithIndex { + blob: Box::new(*bundle.data.get(idx).unwrap()), + index: blob_hash.index, + }) + }) + .collect::, BeaconClientError>>() } } From d98adb4ffc9c2a71ddc6bd3a6e8ea2245659dbcf Mon Sep 17 00:00:00 2001 From: geoknee Date: Fri, 19 Dec 2025 14:51:19 +0000 Subject: [PATCH 09/22] Rename blob sidecar metrics to blob metrics --- crates/providers/providers-alloy/src/blobs.rs | 4 ++-- .../providers/providers-alloy/src/metrics.rs | 19 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/providers/providers-alloy/src/blobs.rs b/crates/providers/providers-alloy/src/blobs.rs index 0e1b455cb8..022a22ac0d 100644 --- a/crates/providers/providers-alloy/src/blobs.rs +++ b/crates/providers/providers-alloy/src/blobs.rs @@ -75,7 +75,7 @@ impl OnlineBlobProvider { slot: u64, blob_hashes: &[IndexedBlobHash], ) -> Result, BlobProviderError> { - kona_macros::inc!(gauge, Metrics::BLOB_SIDECAR_FETCHES); + kona_macros::inc!(gauge, Metrics::BLOB_FETCHES); let result = self .beacon_client @@ -85,7 +85,7 @@ impl OnlineBlobProvider { #[cfg(feature = "metrics")] if result.is_err() { - kona_macros::inc!(gauge, Metrics::BLOB_SIDECAR_FETCH_ERRORS); + kona_macros::inc!(gauge, Metrics::BLOB_FETCH_ERRORS); } result diff --git a/crates/providers/providers-alloy/src/metrics.rs b/crates/providers/providers-alloy/src/metrics.rs index 17c8ac6752..994d460c14 100644 --- a/crates/providers/providers-alloy/src/metrics.rs +++ b/crates/providers/providers-alloy/src/metrics.rs @@ -29,11 +29,11 @@ impl Metrics { /// Identifier for the gauge that tracks L2 chain provider errors. pub const L2_CHAIN_PROVIDER_ERRORS: &str = "kona_providers_l2_chain_errors"; - /// Identifier for the gauge that tracks blob sidecar fetches. - pub const BLOB_SIDECAR_FETCHES: &str = "kona_providers_blob_sidecar_fetches"; + /// Identifier for the gauge that tracks blob fetches. + pub const BLOB_FETCHES: &str = "kona_providers_blob_fetches"; - /// Identifier for the gauge that tracks blob sidecar fetch errors. - pub const BLOB_SIDECAR_FETCH_ERRORS: &str = "kona_providers_blob_sidecar_errors"; + /// Identifier for the gauge that tracks blob fetch errors. + pub const BLOB_FETCH_ERRORS: &str = "kona_providers_blob_fetch_errors"; /// Identifier for the histogram that tracks provider request duration. pub const PROVIDER_REQUEST_DURATION: &str = "kona_providers_request_duration"; @@ -90,11 +90,8 @@ impl Metrics { Self::L2_CHAIN_PROVIDER_ERRORS, "Number of errors in L2 chain provider requests" ); - metrics::describe_gauge!(Self::BLOB_SIDECAR_FETCHES, "Number of blob sidecar fetches"); - metrics::describe_gauge!( - Self::BLOB_SIDECAR_FETCH_ERRORS, - "Number of blob sidecar fetch errors" - ); + metrics::describe_gauge!(Self::BLOB_FETCHES, "Number of blob sidecar fetches"); + metrics::describe_gauge!(Self::BLOB_FETCH_ERRORS, "Number of blob sidecar fetch errors"); metrics::describe_histogram!( Self::PROVIDER_REQUEST_DURATION, "Duration of provider requests in seconds" @@ -195,8 +192,8 @@ impl Metrics { ); // Blob sidecar metrics - kona_macros::set!(gauge, Self::BLOB_SIDECAR_FETCHES, 0); - kona_macros::set!(gauge, Self::BLOB_SIDECAR_FETCH_ERRORS, 0); + kona_macros::set!(gauge, Self::BLOB_FETCHES, 0); + kona_macros::set!(gauge, Self::BLOB_FETCH_ERRORS, 0); // Cache metrics kona_macros::set!(gauge, Self::CACHE_ENTRIES, "cache", "header_by_hash", 0); From 31cd50236faa73e64935c48867a5cf6b5b81d218 Mon Sep 17 00:00:00 2001 From: George Knee Date: Wed, 7 Jan 2026 15:36:50 +0000 Subject: [PATCH 10/22] Apply suggestions from code review Co-authored-by: theo <80177219+theochap@users.noreply.github.com> --- crates/providers/providers-alloy/Cargo.toml | 2 +- crates/providers/providers-alloy/src/beacon_client.rs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/providers/providers-alloy/Cargo.toml b/crates/providers/providers-alloy/Cargo.toml index a522fe1633..734dd25508 100644 --- a/crates/providers/providers-alloy/Cargo.toml +++ b/crates/providers/providers-alloy/Cargo.toml @@ -58,5 +58,5 @@ metrics = [ "dep:metrics", "kona-derive/metrics" ] [dev-dependencies] tokio.workspace = true -httpmock = "0.8.2" +httpmock.workspace = true serde_json.workspace = true diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 69b7a87506..ac2a2202aa 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -87,8 +87,10 @@ pub trait BeaconClient { ) -> Result, Self::Error>; } -/// blob_versioned_hash computes the versioned hash of a blob. -fn blob_versioned_hash(blob: &FixedBytes<131072>) -> B256 { +/// [`blob_versioned_hash`] computes the versioned hash of a blob. +pub const BLOB_SIZE: usize = 131072 + +fn blob_versioned_hash(blob: &FixedBytes) -> B256 { let kzg_settings = EnvKzgSettings::Default; let kzg_blob = Blob::new(blob.0); let commitment = @@ -141,7 +143,7 @@ impl OnlineBeaconClient { } /// Fetches only the blobs corresponding to the provided (versioned) blob hashes - /// from the beacon /eth/v1/beacon/blobs endpoint. + /// from the beacon [`BLOBS_METHOD_PREFIX`] endpoint. /// Blobs are validated against the supplied versioned hashes /// and returned in the same order as the input. async fn filtered_beacon_blobs( From 0aa3dce00e19128b9509b92020a9aa6bf72973eb Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 7 Jan 2026 15:42:06 +0000 Subject: [PATCH 11/22] Add httpmock and make BLOB_SIZE private --- Cargo.toml | 1 + crates/providers/providers-alloy/src/beacon_client.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d5832cb4a8..1e5faa394f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -253,6 +253,7 @@ arbtest = "0.3.2" proptest = "1.9.0" criterion = "0.5.1" mockall = "0.14.0" +httpmock = "0.8.2" # Serialization rkyv = "0.8.12" diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index ac2a2202aa..23f0a75f26 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -87,9 +87,9 @@ pub trait BeaconClient { ) -> Result, Self::Error>; } -/// [`blob_versioned_hash`] computes the versioned hash of a blob. -pub const BLOB_SIZE: usize = 131072 +const BLOB_SIZE: usize = 131072; +/// [`blob_versioned_hash`] computes the versioned hash of a blob. fn blob_versioned_hash(blob: &FixedBytes) -> B256 { let kzg_settings = EnvKzgSettings::Default; let kzg_blob = Blob::new(blob.0); From a09d4660ddc41753b11f5112baaa3dfe77715377 Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 7 Jan 2026 15:48:08 +0000 Subject: [PATCH 12/22] Add docs for BeaconClientError and re-export --- crates/providers/providers-alloy/src/beacon_client.rs | 3 +++ crates/providers/providers-alloy/src/lib.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 23f0a75f26..a6938ef8cb 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -100,11 +100,14 @@ fn blob_versioned_hash(blob: &FixedBytes) -> B256 { use thiserror::Error; +/// An error that can occur when interacting with the beacon client. #[derive(Error, Debug)] pub enum BeaconClientError { + /// HTTP request failed. #[error("HTTP request failed: {0}")] Http(#[from] reqwest::Error), + /// Blob hash not found in beacon response. #[error("Blob hash not found in beacon response {0}")] BlobNotFound(String), } diff --git a/crates/providers/providers-alloy/src/lib.rs b/crates/providers/providers-alloy/src/lib.rs index 16bab6af91..c3559a6dfe 100644 --- a/crates/providers/providers-alloy/src/lib.rs +++ b/crates/providers/providers-alloy/src/lib.rs @@ -7,6 +7,7 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] mod metrics; +pub use beacon_client::BeaconClientError; pub use metrics::Metrics; mod beacon_client; From 5604dc80c414e40e9909811c48063598bff6d6f4 Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 7 Jan 2026 15:59:14 +0000 Subject: [PATCH 13/22] Move thiserror import and simplify error handling --- crates/providers/providers-alloy/src/beacon_client.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index a6938ef8cb..b5cb2ada69 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -10,6 +10,7 @@ use async_trait::async_trait; use c_kzg::Blob; use reqwest::Client; use std::{boxed::Box, format, string::String, vec::Vec}; +use thiserror::Error; /// The config spec engine api method. const SPEC_METHOD: &str = "eth/v1/config/spec"; @@ -98,8 +99,6 @@ fn blob_versioned_hash(blob: &FixedBytes) -> B256 { kzg_to_versioned_hash(commitment.as_slice()) } -use thiserror::Error; - /// An error that can occur when interacting with the beacon client. #[derive(Error, Debug)] pub enum BeaconClientError { @@ -209,7 +208,7 @@ impl BeaconClient for OnlineBeaconClient { kona_macros::inc!(gauge, Metrics::BEACON_CLIENT_ERRORS, "method" => "spec"); } - result.map_err(|err| BeaconClientError::Http(err)) + Ok(result?) } async fn genesis_time(&self) -> Result { From 97ea29006e8886f2d482943e49b2a922125817ae Mon Sep 17 00:00:00 2001 From: geoknee Date: Wed, 7 Jan 2026 15:59:42 +0000 Subject: [PATCH 14/22] Propagate genesis errors with ? operator --- crates/providers/providers-alloy/src/beacon_client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index b5cb2ada69..ee620b80e1 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -224,7 +224,7 @@ impl BeaconClient for OnlineBeaconClient { kona_macros::inc!(gauge, Metrics::BEACON_CLIENT_ERRORS, "method" => "genesis"); } - result.map_err(|err| BeaconClientError::Http(err)) + Ok(result?) } async fn filtered_beacon_blobs( From 65f35f40e0f15a9fc8230e4acdf62913b5a8a715 Mon Sep 17 00:00:00 2001 From: geoknee Date: Thu, 8 Jan 2026 17:13:39 +0000 Subject: [PATCH 15/22] Return Result from blob_versioned_hash Change blob_versioned_hash to return Result and use ? to propagate KZG errors. Add BeaconClientError::KZG(#[from] c_kzg::Error) and update callers and tests to handle the Result. Adjust imports for KZG types. --- .../providers-alloy/src/beacon_client.rs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index ee620b80e1..50be1b6a4a 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -3,7 +3,10 @@ #[cfg(feature = "metrics")] use crate::Metrics; use crate::blobs::BoxedBlobWithIndex; -use alloy_eips::eip4844::{IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash}; +use alloy_eips::eip4844::{ + IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash, + trusted_setup_points::KzgErrors, +}; use alloy_primitives::{B256, FixedBytes}; use alloy_rpc_types_beacon::sidecar::GetBlobsResponse; use async_trait::async_trait; @@ -91,12 +94,11 @@ pub trait BeaconClient { const BLOB_SIZE: usize = 131072; /// [`blob_versioned_hash`] computes the versioned hash of a blob. -fn blob_versioned_hash(blob: &FixedBytes) -> B256 { +fn blob_versioned_hash(blob: &FixedBytes) -> Result { let kzg_settings = EnvKzgSettings::Default; let kzg_blob = Blob::new(blob.0); - let commitment = - kzg_settings.get().blob_to_kzg_commitment(&kzg_blob).map(|blob| blob.to_bytes()).unwrap(); - kzg_to_versioned_hash(commitment.as_slice()) + let commitment = kzg_settings.get().blob_to_kzg_commitment(&kzg_blob)?; + Ok(kzg_to_versioned_hash(commitment.as_slice())) } /// An error that can occur when interacting with the beacon client. @@ -109,6 +111,10 @@ pub enum BeaconClientError { /// Blob hash not found in beacon response. #[error("Blob hash not found in beacon response {0}")] BlobNotFound(String), + + /// KZG error. + #[error("KZG error: {0}")] + KZG(#[from] c_kzg::Error), } /// An online implementation of the [BeaconClient] trait. @@ -166,7 +172,11 @@ impl OnlineBeaconClient { // Map blobs into versioned hashes for validation and // matching against input: - let response_blob_hashes = bundle.data.iter().map(blob_versioned_hash).collect::>(); + let response_blob_hashes = bundle + .data + .iter() + .map(blob_versioned_hash) + .collect::, BeaconClientError>>()?; // Map the input into the output, finding the blob from the response // whose hash matches the input: @@ -260,7 +270,7 @@ mod tests { "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04", ) .unwrap(); - assert_eq!(want, blob_versioned_hash(&input)); + assert_eq!(want, blob_versioned_hash(&input).unwrap()); } #[tokio::test] From 39c01c76a7f01604cbf48a67935b6e78412714be Mon Sep 17 00:00:00 2001 From: geoknee Date: Thu, 8 Jan 2026 21:31:25 +0000 Subject: [PATCH 16/22] Remove unused KzgErrors import --- crates/providers/providers-alloy/src/beacon_client.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 50be1b6a4a..a751b34f5e 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -3,10 +3,7 @@ #[cfg(feature = "metrics")] use crate::Metrics; use crate::blobs::BoxedBlobWithIndex; -use alloy_eips::eip4844::{ - IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash, - trusted_setup_points::KzgErrors, -}; +use alloy_eips::eip4844::{IndexedBlobHash, env_settings::EnvKzgSettings, kzg_to_versioned_hash}; use alloy_primitives::{B256, FixedBytes}; use alloy_rpc_types_beacon::sidecar::GetBlobsResponse; use async_trait::async_trait; From 902a2dc6ab912deacd5ce16007caa04a925c193e Mon Sep 17 00:00:00 2001 From: geoknee Date: Thu, 8 Jan 2026 22:02:11 +0000 Subject: [PATCH 17/22] Attach recomputed hashes to blobs for matching avoids the need to use unwrap --- .../providers-alloy/src/beacon_client.rs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index a751b34f5e..9c807a5dd1 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -164,30 +164,29 @@ impl OnlineBeaconClient { .send() .await? .error_for_status()?; - let bundle = response.json::().await?; - // Map blobs into versioned hashes for validation and - // matching against input: - let response_blob_hashes = bundle + let bundle_with_hashes = bundle .data .iter() - .map(blob_versioned_hash) + .map(|data| -> Result<_, BeaconClientError> { + let recomputed_hash = blob_versioned_hash(data)?; + Ok((data, recomputed_hash)) + }) .collect::, BeaconClientError>>()?; - // Map the input into the output, finding the blob from the response + // Map the input (blob_hashes) into the output, + // finding the blob from the response // whose hash matches the input: blob_hashes .iter() .map(|blob_hash| -> Result { - let idx = response_blob_hashes + let (_, (matching_data, _)) = bundle_with_hashes .iter() - .position(|response_blob_hash| *response_blob_hash == blob_hash.hash) + .enumerate() + .find(|(_, (_, recomputed_hash))| *recomputed_hash == blob_hash.hash) .ok_or(BeaconClientError::BlobNotFound(blob_hash.hash.to_string()))?; - Ok(BoxedBlobWithIndex { - blob: Box::new(*bundle.data.get(idx).unwrap()), - index: blob_hash.index, - }) + Ok(BoxedBlobWithIndex { blob: Box::new(**matching_data), index: blob_hash.index }) }) .collect::, BeaconClientError>>() } From a4ee5259992ea1ed4e6e22cd424965239faa2cf8 Mon Sep 17 00:00:00 2001 From: geoknee Date: Thu, 8 Jan 2026 22:18:37 +0000 Subject: [PATCH 18/22] Collect blobs into HashMap for O(1) lookup --- .../providers/providers-alloy/src/beacon_client.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 9c807a5dd1..c932612646 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -9,7 +9,7 @@ use alloy_rpc_types_beacon::sidecar::GetBlobsResponse; use async_trait::async_trait; use c_kzg::Blob; use reqwest::Client; -use std::{boxed::Box, format, string::String, vec::Vec}; +use std::{boxed::Box, collections::HashMap, format, string::String, vec::Vec}; use thiserror::Error; /// The config spec engine api method. @@ -166,14 +166,14 @@ impl OnlineBeaconClient { .error_for_status()?; let bundle = response.json::().await?; - let bundle_with_hashes = bundle + let returned_blobs_mapped_by_hash = bundle .data .iter() .map(|data| -> Result<_, BeaconClientError> { let recomputed_hash = blob_versioned_hash(data)?; - Ok((data, recomputed_hash)) + Ok((recomputed_hash, data)) }) - .collect::, BeaconClientError>>()?; + .collect::, BeaconClientError>>()?; // Map the input (blob_hashes) into the output, // finding the blob from the response @@ -181,10 +181,8 @@ impl OnlineBeaconClient { blob_hashes .iter() .map(|blob_hash| -> Result { - let (_, (matching_data, _)) = bundle_with_hashes - .iter() - .enumerate() - .find(|(_, (_, recomputed_hash))| *recomputed_hash == blob_hash.hash) + let matching_data = returned_blobs_mapped_by_hash + .get(&blob_hash.hash) .ok_or(BeaconClientError::BlobNotFound(blob_hash.hash.to_string()))?; Ok(BoxedBlobWithIndex { blob: Box::new(**matching_data), index: blob_hash.index }) }) From a63a1d4c6d020cf6359a5f3698d6ea27ab50ded9 Mon Sep 17 00:00:00 2001 From: geoknee Date: Fri, 9 Jan 2026 13:02:04 +0000 Subject: [PATCH 19/22] lint --- .../providers/providers-alloy/src/beacon_client.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index c932612646..dbba00ea7e 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -272,7 +272,7 @@ mod tests { let slot = 987654321; let slot_string = slot.to_string(); let blob_data: Blob = FixedBytes::repeat_byte(1); - let repeated_blob_data: Vec = vec![blob_data.clone(), blob_data.clone()]; + let repeated_blob_data: Vec = vec![blob_data, blob_data]; let repeated_blob_response = json!({ "execution_optimistic": false, "finalized": false, @@ -284,13 +284,13 @@ mod tests { "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04"; let blob_hash_of_interest = FixedBytes::from_hex(blob_hash_of_interest_hex).unwrap(); let required_query_param = - format!("{},{}", blob_hash_of_interest_hex, blob_hash_of_interest_hex); + format!("{blob_hash_of_interest_hex},{blob_hash_of_interest_hex}"); // This server mocks a single, specific query on a beacon node, let server = MockServer::start(); let mut blobs_mock = server.mock(|when, then| { when.method(GET) - .path(format!("/eth/v1/beacon/blobs/{}", slot_string)) + .path(format!("/eth/v1/beacon/blobs/{slot_string}")) .query_param("versioned_hashes", required_query_param.clone()); then.status(200).json_body(repeated_blob_response); }); @@ -309,8 +309,8 @@ mod tests { blobs_mock.assert(); let want: Vec = vec![ - BoxedBlobWithIndex { index: 0, blob: Box::new(blob_data.clone()) }, - BoxedBlobWithIndex { index: 2, blob: Box::new(blob_data.clone()) }, + BoxedBlobWithIndex { index: 0, blob: Box::new(blob_data) }, + BoxedBlobWithIndex { index: 2, blob: Box::new(blob_data) }, ]; assert_eq!(response, want); @@ -324,7 +324,7 @@ mod tests { }); let incorrect_blobs_mock = server.mock(|when, then| { when.method(GET) - .path(format!("/eth/v1/beacon/blobs/{}", slot_string)) + .path(format!("/eth/v1/beacon/blobs/{slot_string}")) .query_param("versioned_hashes", required_query_param.clone()); then.status(200).json_body(incorrect_blob_response); }); From 8d1606d4bca65fb44003f9001f73a373dabd4a32 Mon Sep 17 00:00:00 2001 From: George Knee Date: Mon, 12 Jan 2026 09:17:23 +0000 Subject: [PATCH 20/22] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- crates/providers/providers-alloy/src/beacon_client.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index dbba00ea7e..13b6d9ff11 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -18,7 +18,7 @@ const SPEC_METHOD: &str = "eth/v1/config/spec"; /// The beacon genesis engine api method. const GENESIS_METHOD: &str = "eth/v1/beacon/genesis"; -/// THe blobs engine api method prefix. +/// The blobs engine api method prefix. const BLOBS_METHOD_PREFIX: &str = "eth/v1/beacon/blobs"; /// A reduced genesis data. @@ -106,7 +106,7 @@ pub enum BeaconClientError { Http(#[from] reqwest::Error), /// Blob hash not found in beacon response. - #[error("Blob hash not found in beacon response {0}")] + #[error("Blob hash not found in beacon response: {0}")] BlobNotFound(String), /// KZG error. @@ -160,7 +160,7 @@ impl OnlineBeaconClient { let response = self .inner .get(format!("{}/{}/{}", self.base, BLOBS_METHOD_PREFIX, slot)) - .query(&[("versioned_hashes", ¶ms.join(",").as_str())]) + .query(&[("versioned_hashes", ¶ms.join(","))]) .send() .await? .error_for_status()?; @@ -320,7 +320,7 @@ mod tests { let incorrect_blob_response = json!({ "execution_optimistic": false, "finalized": false, - "data": garbage_blob_data + "data": vec![garbage_blob_data] }); let incorrect_blobs_mock = server.mock(|when, then| { when.method(GET) From 964544359c927d71aefca4f672f883af986066be Mon Sep 17 00:00:00 2001 From: geoknee Date: Mon, 12 Jan 2026 10:18:15 +0000 Subject: [PATCH 21/22] refactor test with a loop --- .../providers-alloy/src/beacon_client.rs | 139 +++++++++--------- 1 file changed, 66 insertions(+), 73 deletions(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 13b6d9ff11..5d2f55e175 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -257,88 +257,81 @@ mod tests { use httpmock::prelude::*; use serde_json::json; + const TEST_BLOB_DATA: Blob = FixedBytes::repeat_byte(1); + const TEST_BLOB_HASH_HEX: &str = + "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04"; + #[test] fn test_blob_versioned_hash() { let input: Blob = FixedBytes::repeat_byte(1); - let want: FixedBytes<32> = FixedBytes::from_hex( - "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04", - ) - .unwrap(); - assert_eq!(want, blob_versioned_hash(&input).unwrap()); + let test_blob_hash: FixedBytes<32> = FixedBytes::from_hex(TEST_BLOB_HASH_HEX).unwrap(); + assert_eq!(test_blob_hash, blob_versioned_hash(&input).unwrap()); } #[tokio::test] async fn test_filtered_beacon_blobs() { let slot = 987654321; let slot_string = slot.to_string(); - let blob_data: Blob = FixedBytes::repeat_byte(1); - let repeated_blob_data: Vec = vec![blob_data, blob_data]; - let repeated_blob_response = json!({ - "execution_optimistic": false, - "finalized": false, - "data": repeated_blob_data - }); - - // The following hash corresponds to the all 01s blob_data (see test above): - let blob_hash_of_interest_hex = - "0x016c357b8b3a6b3fd82386e7bebf77143d537cdb1c856509661c412602306a04"; - let blob_hash_of_interest = FixedBytes::from_hex(blob_hash_of_interest_hex).unwrap(); - let required_query_param = - format!("{blob_hash_of_interest_hex},{blob_hash_of_interest_hex}"); - - // This server mocks a single, specific query on a beacon node, - let server = MockServer::start(); - let mut blobs_mock = server.mock(|when, then| { - when.method(GET) - .path(format!("/eth/v1/beacon/blobs/{slot_string}")) - .query_param("versioned_hashes", required_query_param.clone()); - then.status(200).json_body(repeated_blob_response); - }); - - let client = OnlineBeaconClient::new_http(server.base_url()); - let response = client - .filtered_beacon_blobs( - slot, - &[ - IndexedBlobHash { index: 0, hash: blob_hash_of_interest }, - IndexedBlobHash { index: 2, hash: blob_hash_of_interest }, - ], - ) // ask for blobs 0 and 2, which happen to have identical data and hashes - .await - .unwrap(); - blobs_mock.assert(); - - let want: Vec = vec![ - BoxedBlobWithIndex { index: 0, blob: Box::new(blob_data) }, - BoxedBlobWithIndex { index: 2, blob: Box::new(blob_data) }, + let repeated_blob_data: Vec = vec![TEST_BLOB_DATA, TEST_BLOB_DATA]; + let garbage_blob_data: Vec = vec![FixedBytes::repeat_byte(2)]; + let required_query_param = format!("{TEST_BLOB_HASH_HEX},{TEST_BLOB_HASH_HEX}"); + let test_blob_hash: FixedBytes<32> = FixedBytes::from_hex(TEST_BLOB_HASH_HEX).unwrap(); + let requested_blob_hashes: Vec = vec![ + IndexedBlobHash { index: 0, hash: test_blob_hash }, + IndexedBlobHash { index: 2, hash: test_blob_hash }, ]; - assert_eq!(response, want); - - // Replace the mock with one which will provide an incorrect response - blobs_mock.delete(); - let garbage_blob_data: Blob = FixedBytes::repeat_byte(2); - let incorrect_blob_response = json!({ - "execution_optimistic": false, - "finalized": false, - "data": vec![garbage_blob_data] - }); - let incorrect_blobs_mock = server.mock(|when, then| { - when.method(GET) - .path(format!("/eth/v1/beacon/blobs/{slot_string}")) - .query_param("versioned_hashes", required_query_param.clone()); - then.status(200).json_body(incorrect_blob_response); - }); - - client - .filtered_beacon_blobs( - slot, - &[ - IndexedBlobHash { index: 0, hash: blob_hash_of_interest }, - IndexedBlobHash { index: 2, hash: blob_hash_of_interest }, - ], - ) // ask for blobs 0 and 2, which happen to have identical data and hashes - .await - .expect_err("Expected error when mocking an incorrect response from the beacon server"); - incorrect_blobs_mock.assert(); + + struct TestCase { + name: &'static str, + mock_response_data: Vec, + want: Option>, // if none, expect an error + } + + let test_cases = vec![ + TestCase { + name: "Repeated Blob Data, expect success", + mock_response_data: repeated_blob_data, + want: Some(vec![ + BoxedBlobWithIndex { index: 0, blob: Box::new(TEST_BLOB_DATA) }, + BoxedBlobWithIndex { index: 2, blob: Box::new(TEST_BLOB_DATA) }, + ]), + }, + TestCase { + name: "Garbage Blob Data, expect error", + mock_response_data: garbage_blob_data, + want: None, // indicates an error is expected + }, + ]; + + let server = MockServer::start(); + for test_case in test_cases { + // This server mocks a single, specific query on a beacon node, + let mock_response = json!({ + "execution_optimistic": false, + "finalized": false, + "data": test_case.mock_response_data + }); + let mut blobs_mock = server.mock(|when, then| { + when.method(GET) + .path(format!("/eth/v1/beacon/blobs/{slot_string}")) + .query_param("versioned_hashes", required_query_param.clone()); + then.status(200).json_body(mock_response); + }); + + let client = OnlineBeaconClient::new_http(server.base_url()); + let response = client.filtered_beacon_blobs(slot, &requested_blob_hashes).await; + blobs_mock.assert(); + match test_case.want { + Some(s) => { + let r = response.unwrap(); + assert_eq!(r.len(), s.len(), "length mistmatch{}", test_case.name); + assert_eq!(r, s, "{}", test_case.name) + } + None => { + assert!(response.is_err(), "{}", test_case.name) + } + } + blobs_mock.delete(); + } } } From ad41ad855c2c37b335badadf6ab22f4cdbecee45 Mon Sep 17 00:00:00 2001 From: geoknee Date: Mon, 12 Jan 2026 10:21:30 +0000 Subject: [PATCH 22/22] typo --- crates/providers/providers-alloy/src/beacon_client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/providers/providers-alloy/src/beacon_client.rs b/crates/providers/providers-alloy/src/beacon_client.rs index 5d2f55e175..cacaf9d511 100644 --- a/crates/providers/providers-alloy/src/beacon_client.rs +++ b/crates/providers/providers-alloy/src/beacon_client.rs @@ -324,7 +324,7 @@ mod tests { match test_case.want { Some(s) => { let r = response.unwrap(); - assert_eq!(r.len(), s.len(), "length mistmatch{}", test_case.name); + assert_eq!(r.len(), s.len(), "length mismatch{}", test_case.name); assert_eq!(r, s, "{}", test_case.name) } None => {