Skip to main content

ethrex_rpc/engine/
blobs.rs

1use ethrex_common::{
2    H256,
3    serde_utils::{self},
4    types::{Blob, Proof},
5};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use tracing::debug;
9
10use crate::{
11    rpc::{RpcApiContext, RpcHandler},
12    utils::RpcErr,
13};
14
15// -> https://github.com/ethereum/execution-apis/blob/d41fdf10fabbb73c4d126fb41809785d830acace/src/engine/cancun.md?plain=1#L186
16const GET_BLOBS_V1_REQUEST_MAX_SIZE: usize = 128;
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct BlobsV1Request {
20    blob_versioned_hashes: Vec<H256>,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct BlobsV2Request {
25    blob_versioned_hashes: Vec<H256>,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29pub struct BlobsV3Request {
30    blob_versioned_hashes: Vec<H256>,
31}
32
33#[derive(Clone, Debug, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct BlobAndProofV1 {
36    #[serde(with = "serde_utils::blob")]
37    pub blob: Blob,
38    #[serde(with = "serde_utils::bytes48")]
39    pub proof: Proof,
40}
41
42#[derive(Clone, Debug, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct BlobAndProofV2 {
45    #[serde(with = "serde_utils::blob")]
46    pub blob: Blob,
47    #[serde(with = "serde_utils::bytes48::vec")]
48    pub proofs: Vec<Proof>,
49}
50
51impl RpcHandler for BlobsV1Request {
52    fn parse(params: &Option<Vec<Value>>) -> Result<Self, RpcErr> {
53        let params = params
54            .as_ref()
55            .ok_or(RpcErr::BadParams("No params provided".to_owned()))?;
56        if params.len() != 1 {
57            return Err(RpcErr::BadParams("Expected 1 param".to_owned()));
58        };
59        Ok(BlobsV1Request {
60            blob_versioned_hashes: serde_json::from_value(params[0].clone())?,
61        })
62    }
63
64    async fn handle(&self, context: RpcApiContext) -> Result<Value, RpcErr> {
65        debug!("Received new engine request: Requested Blobs");
66
67        // Intentional fall-through: before a canonical tip exists, there is no
68        // block timestamp to compare against Osaka, so the node is treated as pre-Osaka.
69        if let Some(current_block_header) = context
70            .storage
71            .get_block_header(context.storage.get_latest_block_number().await?)?
72            && context
73                .storage
74                .get_chain_config()
75                .is_osaka_activated(current_block_header.timestamp)
76        {
77            return Err(RpcErr::UnsupportedFork(
78                "getBlobsV1 engine only supported before Osaka".to_string(),
79            ));
80        }
81
82        if self.blob_versioned_hashes.len() > GET_BLOBS_V1_REQUEST_MAX_SIZE {
83            return Err(RpcErr::TooLargeRequest);
84        }
85
86        let blob_tuples = context
87            .blockchain
88            .mempool
89            .get_blobs_data_by_versioned_hashes(&self.blob_versioned_hashes)?;
90
91        debug_assert_eq!(self.blob_versioned_hashes.len(), blob_tuples.len());
92
93        let res: Vec<Option<BlobAndProofV1>> = blob_tuples
94            .into_iter()
95            .map(|b| {
96                b.and_then(|(blob, _, proofs)| {
97                    // getBlobsV1 serves the single EIP-4844 blob proof. A v0 bundle yields
98                    // exactly one proof here (see `get_blob_tuple_by_index`); a v1 (EIP-7594)
99                    // sidecar yields 128 cell proofs per blob and can now reach a pre-Osaka
100                    // mempool. Cell proofs can't be represented as a single blob proof, so
101                    // report the blob as unavailable (the CL re-fetches it from gossip)
102                    // rather than returning a cell proof in the blob-proof field.
103                    (proofs.len() == 1).then(|| BlobAndProofV1 {
104                        blob: *blob,
105                        proof: proofs[0],
106                    })
107                })
108            })
109            .collect();
110
111        serde_json::to_value(res).map_err(|error| RpcErr::Internal(error.to_string()))
112    }
113}
114
115impl RpcHandler for BlobsV2Request {
116    fn parse(params: &Option<Vec<Value>>) -> Result<Self, RpcErr> {
117        let params = params
118            .as_ref()
119            .ok_or(RpcErr::BadParams("No params provided".to_owned()))?;
120        if params.len() != 1 {
121            return Err(RpcErr::BadParams("Expected 1 param".to_owned()));
122        };
123        Ok(BlobsV2Request {
124            blob_versioned_hashes: serde_json::from_value(params[0].clone())?,
125        })
126    }
127
128    async fn handle(&self, context: RpcApiContext) -> Result<Value, RpcErr> {
129        debug!("Received new engine request: Requested Blobs V2");
130        let res = get_blobs_and_proof(&self.blob_versioned_hashes, context).await?;
131        if res.iter().any(|blob| blob.is_none()) {
132            return Ok(Value::Null);
133        }
134        serde_json::to_value(res).map_err(|error| RpcErr::Internal(error.to_string()))
135    }
136}
137
138impl RpcHandler for BlobsV3Request {
139    fn parse(params: &Option<Vec<Value>>) -> Result<Self, RpcErr> {
140        let params = params
141            .as_ref()
142            .ok_or(RpcErr::BadParams("No params provided".to_owned()))?;
143        if params.len() != 1 {
144            return Err(RpcErr::BadParams("Expected 1 param".to_owned()));
145        };
146        Ok(BlobsV3Request {
147            blob_versioned_hashes: serde_json::from_value(params[0].clone())?,
148        })
149    }
150
151    async fn handle(&self, context: RpcApiContext) -> Result<Value, RpcErr> {
152        debug!("Received new engine request: Requested Blobs V3");
153        let res = get_blobs_and_proof(&self.blob_versioned_hashes, context).await?;
154        serde_json::to_value(res).map_err(|error| RpcErr::Internal(error.to_string()))
155    }
156}
157
158/// Get blob data and proofs for a given list of blob versioned hashes.
159async fn get_blobs_and_proof(
160    blob_versioned_hashes: &[H256],
161    context: RpcApiContext,
162) -> Result<Vec<Option<BlobAndProofV2>>, RpcErr> {
163    if blob_versioned_hashes.len() > GET_BLOBS_V1_REQUEST_MAX_SIZE {
164        return Err(RpcErr::TooLargeRequest);
165    }
166
167    // getBlobsV2/V3 (EIP-7594) serve cell proofs, which only exist once the chain is at
168    // Osaka. The engine spec does NOT define a pre-fork `-38005` for these methods (that
169    // code is for the opposite direction, e.g. getBlobsV1 *after* Osaka); their contract is
170    // simply to return `null` for any blob we don't have. So before our canonical tip is at
171    // Osaka, return `null` for every requested hash rather than a bespoke error. This also
172    // covers the syncing case, where the local head is still pre-Osaka while we catch up
173    // (the spec likewise prescribes `null` while syncing).
174    let head_is_osaka = match context
175        .storage
176        .get_block_header(context.storage.get_latest_block_number().await?)?
177    {
178        Some(current_block_header) => context
179            .storage
180            .get_chain_config()
181            .is_osaka_activated(current_block_header.timestamp),
182        // No canonical tip yet: treat as pre-Osaka.
183        None => false,
184    };
185    if !head_is_osaka {
186        return Ok(vec![None; blob_versioned_hashes.len()]);
187    }
188
189    let blob_tuples = context
190        .blockchain
191        .mempool
192        .get_blobs_data_by_versioned_hashes(blob_versioned_hashes)?;
193
194    debug_assert_eq!(blob_versioned_hashes.len(), blob_tuples.len());
195
196    let res = blob_tuples
197        .into_iter()
198        .map(|b| {
199            b.map(|(blob, _, proofs)| BlobAndProofV2 {
200                blob: *blob,
201                proofs,
202            })
203        })
204        .collect();
205
206    Ok(res)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::test_utils::default_context_with_storage;
213    use ethrex_common::{
214        Address, H256,
215        types::{
216            BYTES_PER_BLOB, BlobsBundle, CELLS_PER_EXT_BLOB, ChainConfig, Commitment, Proof,
217            kzg_commitment_to_versioned_hash,
218        },
219    };
220    use ethrex_storage::{EngineType, Store};
221
222    fn sample_bundle(count: usize) -> (BlobsBundle, Vec<H256>) {
223        let blobs = vec![[1u8; BYTES_PER_BLOB]; count];
224        let commitments: Vec<Commitment> = (0..count).map(|i| [i as u8; 48]).collect();
225        let proofs: Vec<Proof> = vec![[2u8; 48]; count * CELLS_PER_EXT_BLOB];
226
227        let hashes = commitments
228            .iter()
229            .map(kzg_commitment_to_versioned_hash)
230            .collect();
231
232        let bundle = BlobsBundle {
233            blobs,
234            commitments,
235            proofs,
236            version: 1,
237        };
238        (bundle, hashes)
239    }
240
241    fn sample_v0_bundle(count: usize) -> (BlobsBundle, Vec<H256>) {
242        let blobs = vec![[1u8; BYTES_PER_BLOB]; count];
243        let commitments: Vec<Commitment> = (0..count).map(|i| [i as u8; 48]).collect();
244        // v0 (EIP-4844): exactly one blob proof per blob.
245        let proofs: Vec<Proof> = vec![[2u8; 48]; count];
246
247        let hashes = commitments
248            .iter()
249            .map(kzg_commitment_to_versioned_hash)
250            .collect();
251
252        let bundle = BlobsBundle {
253            blobs,
254            commitments,
255            proofs,
256            version: 0,
257        };
258        (bundle, hashes)
259    }
260
261    fn blob_and_proof(bundle: &BlobsBundle, index: usize) -> BlobAndProofV2 {
262        let start = index * CELLS_PER_EXT_BLOB;
263        let end = start + CELLS_PER_EXT_BLOB;
264        BlobAndProofV2 {
265            blob: bundle.blobs[index],
266            proofs: bundle.proofs[start..end].to_vec(),
267        }
268    }
269
270    fn chain_config(osaka_active: bool) -> ChainConfig {
271        ChainConfig {
272            chain_id: 1,
273            shanghai_time: Some(0),
274            cancun_time: Some(0),
275            prague_time: Some(0),
276            osaka_time: osaka_active.then_some(0),
277            deposit_contract_address: Address::zero(),
278            ..Default::default()
279        }
280    }
281
282    async fn context_with_chain_config(osaka_active: bool) -> RpcApiContext {
283        let mut storage =
284            Store::new("test-blobs", EngineType::InMemory).expect("Failed to create test store");
285        storage
286            .set_chain_config(&chain_config(osaka_active))
287            .await
288            .expect("Failed to set chain config");
289        default_context_with_storage(storage).await
290    }
291
292    #[tokio::test]
293    async fn blobs_v2_returns_null_when_missing_one() {
294        let context = context_with_chain_config(true).await;
295        let (bundle, hashes) = sample_bundle(2);
296        context
297            .blockchain
298            .mempool
299            .add_blobs_bundle(H256::from_low_u64_be(1), bundle)
300            .unwrap();
301
302        let request = BlobsV2Request {
303            blob_versioned_hashes: vec![hashes[0], H256::from_low_u64_be(999)],
304        };
305
306        let result = request.handle(context).await.unwrap();
307        assert_eq!(result, serde_json::Value::Null);
308    }
309
310    #[tokio::test]
311    async fn blobs_v2_returns_full_when_all_present() {
312        let context = context_with_chain_config(true).await;
313        let (bundle, hashes) = sample_bundle(2);
314        context
315            .blockchain
316            .mempool
317            .add_blobs_bundle(H256::from_low_u64_be(1), bundle.clone())
318            .unwrap();
319
320        let request = BlobsV2Request {
321            blob_versioned_hashes: hashes.clone(),
322        };
323
324        let result = request.handle(context).await.unwrap();
325        let expected = serde_json::to_value(vec![
326            Some(blob_and_proof(&bundle, 0)),
327            Some(blob_and_proof(&bundle, 1)),
328        ])
329        .unwrap();
330        assert_eq!(result, expected);
331    }
332
333    #[tokio::test]
334    async fn blobs_v3_returns_partial_results() {
335        let context = context_with_chain_config(true).await;
336        let (bundle, hashes) = sample_bundle(2);
337        context
338            .blockchain
339            .mempool
340            .add_blobs_bundle(H256::from_low_u64_be(1), bundle.clone())
341            .unwrap();
342
343        let request = BlobsV3Request {
344            blob_versioned_hashes: vec![hashes[0], H256::from_low_u64_be(999)],
345        };
346
347        let result = request.handle(context).await.unwrap();
348        let expected = serde_json::to_value(vec![Some(blob_and_proof(&bundle, 0)), None]).unwrap();
349        assert_eq!(result, expected);
350    }
351
352    #[tokio::test]
353    async fn blobs_v1_returns_v0_proof_before_osaka() {
354        let context = context_with_chain_config(false).await;
355        let (bundle, hashes) = sample_v0_bundle(1);
356        context
357            .blockchain
358            .mempool
359            .add_blobs_bundle(H256::from_low_u64_be(1), bundle.clone())
360            .unwrap();
361
362        let request = BlobsV1Request {
363            blob_versioned_hashes: hashes,
364        };
365
366        let result = request.handle(context).await.unwrap();
367        let expected = serde_json::to_value(vec![Some(BlobAndProofV1 {
368            blob: bundle.blobs[0],
369            proof: bundle.proofs[0],
370        })])
371        .unwrap();
372        assert_eq!(result, expected);
373    }
374
375    #[tokio::test]
376    async fn blobs_v1_returns_null_for_v1_sidecar_before_osaka() {
377        // A v1 (cell-proof) sidecar can reach a pre-Osaka mempool, but getBlobsV1 can only
378        // serve a single EIP-4844 blob proof, so it must report the blob as unavailable
379        // rather than returning a cell proof in the blob-proof field.
380        let context = context_with_chain_config(false).await;
381        let (bundle, hashes) = sample_bundle(1);
382        context
383            .blockchain
384            .mempool
385            .add_blobs_bundle(H256::from_low_u64_be(1), bundle)
386            .unwrap();
387
388        let request = BlobsV1Request {
389            blob_versioned_hashes: hashes,
390        };
391
392        let result = request.handle(context).await.unwrap();
393        let expected = serde_json::to_value(vec![None::<BlobAndProofV1>]).unwrap();
394        assert_eq!(result, expected);
395    }
396
397    #[tokio::test]
398    async fn blobs_v1_rejects_after_osaka() {
399        let context = context_with_chain_config(true).await;
400        let request = BlobsV1Request {
401            blob_versioned_hashes: vec![H256::from_low_u64_be(1)],
402        };
403
404        let err = request.handle(context).await.unwrap_err();
405        assert!(matches!(err, RpcErr::UnsupportedFork(_)));
406    }
407
408    #[tokio::test]
409    async fn blobs_v3_returns_null_before_osaka() {
410        // Pre-Osaka, getBlobsV3 must not error: the spec contract is to return `null`
411        // for blobs we don't have (which, pre-Osaka, is all of them). Returning a bespoke
412        // -38005 here is a spec misread and spams the CL while the node is still syncing.
413        let context = context_with_chain_config(false).await;
414        let request = BlobsV3Request {
415            blob_versioned_hashes: vec![H256::from_low_u64_be(1), H256::from_low_u64_be(2)],
416        };
417
418        let result = request.handle(context).await.unwrap();
419        let expected = serde_json::to_value(vec![None::<BlobAndProofV2>, None]).unwrap();
420        assert_eq!(result, expected);
421    }
422
423    #[tokio::test]
424    async fn blobs_v3_rejects_too_many_hashes() {
425        let context = context_with_chain_config(true).await;
426        let request = BlobsV3Request {
427            blob_versioned_hashes: vec![H256::zero(); GET_BLOBS_V1_REQUEST_MAX_SIZE + 1],
428        };
429
430        let err = request.handle(context).await.unwrap_err();
431        assert!(matches!(err, RpcErr::TooLargeRequest));
432    }
433
434    #[tokio::test]
435    async fn blobs_v3_accepts_exactly_max_size() {
436        // Spec: clients MUST support at least MAX hashes, so exactly MAX must not be rejected.
437        let context = context_with_chain_config(true).await;
438        let request = BlobsV3Request {
439            blob_versioned_hashes: vec![H256::zero(); GET_BLOBS_V1_REQUEST_MAX_SIZE],
440        };
441        let result = request.handle(context).await;
442        assert!(!matches!(result, Err(RpcErr::TooLargeRequest)));
443    }
444
445    #[tokio::test]
446    async fn blobs_v1_accepts_exactly_max_size_before_osaka() {
447        let context = context_with_chain_config(false).await;
448        let request = BlobsV1Request {
449            blob_versioned_hashes: vec![H256::zero(); GET_BLOBS_V1_REQUEST_MAX_SIZE],
450        };
451        let result = request.handle(context).await;
452        assert!(!matches!(result, Err(RpcErr::TooLargeRequest)));
453    }
454}