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.map(|(blob, _, proofs)| BlobAndProofV1 {
97                    blob: *blob,
98                    // If blob bundle version is 0 then the proofs vec will have only one proof.
99                    // Look at `get_blob_tuple_by_index` for reference.
100                    proof: proofs[0],
101                })
102            })
103            .collect();
104
105        serde_json::to_value(res).map_err(|error| RpcErr::Internal(error.to_string()))
106    }
107}
108
109impl RpcHandler for BlobsV2Request {
110    fn parse(params: &Option<Vec<Value>>) -> Result<Self, RpcErr> {
111        let params = params
112            .as_ref()
113            .ok_or(RpcErr::BadParams("No params provided".to_owned()))?;
114        if params.len() != 1 {
115            return Err(RpcErr::BadParams("Expected 1 param".to_owned()));
116        };
117        Ok(BlobsV2Request {
118            blob_versioned_hashes: serde_json::from_value(params[0].clone())?,
119        })
120    }
121
122    async fn handle(&self, context: RpcApiContext) -> Result<Value, RpcErr> {
123        debug!("Received new engine request: Requested Blobs V2");
124        let res = get_blobs_and_proof(&self.blob_versioned_hashes, context).await?;
125        if res.iter().any(|blob| blob.is_none()) {
126            return Ok(Value::Null);
127        }
128        serde_json::to_value(res).map_err(|error| RpcErr::Internal(error.to_string()))
129    }
130}
131
132impl RpcHandler for BlobsV3Request {
133    fn parse(params: &Option<Vec<Value>>) -> Result<Self, RpcErr> {
134        let params = params
135            .as_ref()
136            .ok_or(RpcErr::BadParams("No params provided".to_owned()))?;
137        if params.len() != 1 {
138            return Err(RpcErr::BadParams("Expected 1 param".to_owned()));
139        };
140        Ok(BlobsV3Request {
141            blob_versioned_hashes: serde_json::from_value(params[0].clone())?,
142        })
143    }
144
145    async fn handle(&self, context: RpcApiContext) -> Result<Value, RpcErr> {
146        debug!("Received new engine request: Requested Blobs V3");
147        let res = get_blobs_and_proof(&self.blob_versioned_hashes, context).await?;
148        serde_json::to_value(res).map_err(|error| RpcErr::Internal(error.to_string()))
149    }
150}
151
152/// Get blob data and proofs for a given list of blob versioned hashes.
153async fn get_blobs_and_proof(
154    blob_versioned_hashes: &[H256],
155    context: RpcApiContext,
156) -> Result<Vec<Option<BlobAndProofV2>>, RpcErr> {
157    if blob_versioned_hashes.len() > GET_BLOBS_V1_REQUEST_MAX_SIZE {
158        return Err(RpcErr::TooLargeRequest);
159    }
160
161    // getBlobsV2/V3 (EIP-7594) serve cell proofs, which only exist once the chain is at
162    // Osaka. The engine spec does NOT define a pre-fork `-38005` for these methods (that
163    // code is for the opposite direction, e.g. getBlobsV1 *after* Osaka); their contract is
164    // simply to return `null` for any blob we don't have. So before our canonical tip is at
165    // Osaka, return `null` for every requested hash rather than a bespoke error. This also
166    // covers the syncing case, where the local head is still pre-Osaka while we catch up
167    // (the spec likewise prescribes `null` while syncing).
168    let head_is_osaka = match context
169        .storage
170        .get_block_header(context.storage.get_latest_block_number().await?)?
171    {
172        Some(current_block_header) => context
173            .storage
174            .get_chain_config()
175            .is_osaka_activated(current_block_header.timestamp),
176        // No canonical tip yet: treat as pre-Osaka.
177        None => false,
178    };
179    if !head_is_osaka {
180        return Ok(vec![None; blob_versioned_hashes.len()]);
181    }
182
183    let blob_tuples = context
184        .blockchain
185        .mempool
186        .get_blobs_data_by_versioned_hashes(blob_versioned_hashes)?;
187
188    debug_assert_eq!(blob_versioned_hashes.len(), blob_tuples.len());
189
190    let res = blob_tuples
191        .into_iter()
192        .map(|b| {
193            b.map(|(blob, _, proofs)| BlobAndProofV2 {
194                blob: *blob,
195                proofs,
196            })
197        })
198        .collect();
199
200    Ok(res)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::test_utils::default_context_with_storage;
207    use ethrex_common::{
208        Address, H256,
209        types::{
210            BYTES_PER_BLOB, BlobsBundle, CELLS_PER_EXT_BLOB, ChainConfig, Commitment, Proof,
211            kzg_commitment_to_versioned_hash,
212        },
213    };
214    use ethrex_storage::{EngineType, Store};
215
216    fn sample_bundle(count: usize) -> (BlobsBundle, Vec<H256>) {
217        let blobs = vec![[1u8; BYTES_PER_BLOB]; count];
218        let commitments: Vec<Commitment> = (0..count).map(|i| [i as u8; 48]).collect();
219        let proofs: Vec<Proof> = vec![[2u8; 48]; count * CELLS_PER_EXT_BLOB];
220
221        let hashes = commitments
222            .iter()
223            .map(kzg_commitment_to_versioned_hash)
224            .collect();
225
226        let bundle = BlobsBundle {
227            blobs,
228            commitments,
229            proofs,
230            version: 1,
231        };
232        (bundle, hashes)
233    }
234
235    fn blob_and_proof(bundle: &BlobsBundle, index: usize) -> BlobAndProofV2 {
236        let start = index * CELLS_PER_EXT_BLOB;
237        let end = start + CELLS_PER_EXT_BLOB;
238        BlobAndProofV2 {
239            blob: bundle.blobs[index],
240            proofs: bundle.proofs[start..end].to_vec(),
241        }
242    }
243
244    fn chain_config(osaka_active: bool) -> ChainConfig {
245        ChainConfig {
246            chain_id: 1,
247            shanghai_time: Some(0),
248            cancun_time: Some(0),
249            prague_time: Some(0),
250            osaka_time: osaka_active.then_some(0),
251            deposit_contract_address: Address::zero(),
252            ..Default::default()
253        }
254    }
255
256    async fn context_with_chain_config(osaka_active: bool) -> RpcApiContext {
257        let mut storage =
258            Store::new("test-blobs", EngineType::InMemory).expect("Failed to create test store");
259        storage
260            .set_chain_config(&chain_config(osaka_active))
261            .await
262            .expect("Failed to set chain config");
263        default_context_with_storage(storage).await
264    }
265
266    #[tokio::test]
267    async fn blobs_v2_returns_null_when_missing_one() {
268        let context = context_with_chain_config(true).await;
269        let (bundle, hashes) = sample_bundle(2);
270        context
271            .blockchain
272            .mempool
273            .add_blobs_bundle(H256::from_low_u64_be(1), bundle)
274            .unwrap();
275
276        let request = BlobsV2Request {
277            blob_versioned_hashes: vec![hashes[0], H256::from_low_u64_be(999)],
278        };
279
280        let result = request.handle(context).await.unwrap();
281        assert_eq!(result, serde_json::Value::Null);
282    }
283
284    #[tokio::test]
285    async fn blobs_v2_returns_full_when_all_present() {
286        let context = context_with_chain_config(true).await;
287        let (bundle, hashes) = sample_bundle(2);
288        context
289            .blockchain
290            .mempool
291            .add_blobs_bundle(H256::from_low_u64_be(1), bundle.clone())
292            .unwrap();
293
294        let request = BlobsV2Request {
295            blob_versioned_hashes: hashes.clone(),
296        };
297
298        let result = request.handle(context).await.unwrap();
299        let expected = serde_json::to_value(vec![
300            Some(blob_and_proof(&bundle, 0)),
301            Some(blob_and_proof(&bundle, 1)),
302        ])
303        .unwrap();
304        assert_eq!(result, expected);
305    }
306
307    #[tokio::test]
308    async fn blobs_v3_returns_partial_results() {
309        let context = context_with_chain_config(true).await;
310        let (bundle, hashes) = sample_bundle(2);
311        context
312            .blockchain
313            .mempool
314            .add_blobs_bundle(H256::from_low_u64_be(1), bundle.clone())
315            .unwrap();
316
317        let request = BlobsV3Request {
318            blob_versioned_hashes: vec![hashes[0], H256::from_low_u64_be(999)],
319        };
320
321        let result = request.handle(context).await.unwrap();
322        let expected = serde_json::to_value(vec![Some(blob_and_proof(&bundle, 0)), None]).unwrap();
323        assert_eq!(result, expected);
324    }
325
326    #[tokio::test]
327    async fn blobs_v1_returns_full_before_osaka() {
328        let context = context_with_chain_config(false).await;
329        let (bundle, hashes) = sample_bundle(1);
330        context
331            .blockchain
332            .mempool
333            .add_blobs_bundle(H256::from_low_u64_be(1), bundle.clone())
334            .unwrap();
335
336        let request = BlobsV1Request {
337            blob_versioned_hashes: hashes,
338        };
339
340        let result = request.handle(context).await.unwrap();
341        let expected = serde_json::to_value(vec![Some(BlobAndProofV1 {
342            blob: bundle.blobs[0],
343            proof: bundle.proofs[0],
344        })])
345        .unwrap();
346        assert_eq!(result, expected);
347    }
348
349    #[tokio::test]
350    async fn blobs_v1_rejects_after_osaka() {
351        let context = context_with_chain_config(true).await;
352        let request = BlobsV1Request {
353            blob_versioned_hashes: vec![H256::from_low_u64_be(1)],
354        };
355
356        let err = request.handle(context).await.unwrap_err();
357        assert!(matches!(err, RpcErr::UnsupportedFork(_)));
358    }
359
360    #[tokio::test]
361    async fn blobs_v3_returns_null_before_osaka() {
362        // Pre-Osaka, getBlobsV3 must not error: the spec contract is to return `null`
363        // for blobs we don't have (which, pre-Osaka, is all of them). Returning a bespoke
364        // -38005 here is a spec misread and spams the CL while the node is still syncing.
365        let context = context_with_chain_config(false).await;
366        let request = BlobsV3Request {
367            blob_versioned_hashes: vec![H256::from_low_u64_be(1), H256::from_low_u64_be(2)],
368        };
369
370        let result = request.handle(context).await.unwrap();
371        let expected = serde_json::to_value(vec![None::<BlobAndProofV2>, None]).unwrap();
372        assert_eq!(result, expected);
373    }
374
375    #[tokio::test]
376    async fn blobs_v3_rejects_too_many_hashes() {
377        let context = context_with_chain_config(true).await;
378        let request = BlobsV3Request {
379            blob_versioned_hashes: vec![H256::zero(); GET_BLOBS_V1_REQUEST_MAX_SIZE + 1],
380        };
381
382        let err = request.handle(context).await.unwrap_err();
383        assert!(matches!(err, RpcErr::TooLargeRequest));
384    }
385
386    #[tokio::test]
387    async fn blobs_v3_accepts_exactly_max_size() {
388        // Spec: clients MUST support at least MAX hashes, so exactly MAX must not be rejected.
389        let context = context_with_chain_config(true).await;
390        let request = BlobsV3Request {
391            blob_versioned_hashes: vec![H256::zero(); GET_BLOBS_V1_REQUEST_MAX_SIZE],
392        };
393        let result = request.handle(context).await;
394        assert!(!matches!(result, Err(RpcErr::TooLargeRequest)));
395    }
396
397    #[tokio::test]
398    async fn blobs_v1_accepts_exactly_max_size_before_osaka() {
399        let context = context_with_chain_config(false).await;
400        let request = BlobsV1Request {
401            blob_versioned_hashes: vec![H256::zero(); GET_BLOBS_V1_REQUEST_MAX_SIZE],
402        };
403        let result = request.handle(context).await;
404        assert!(!matches!(result, Err(RpcErr::TooLargeRequest)));
405    }
406}