Skip to main content

ethrex_common/types/
blobs_bundle.rs

1use std::ops::AddAssign;
2
3use crate::serde_utils;
4#[cfg(feature = "c-kzg")]
5use crate::types::Fork;
6use crate::types::constants::VERSIONED_HASH_VERSION_KZG;
7use crate::{Bytes, H256};
8
9use ethrex_rlp::{
10    decode::RLPDecode,
11    encode::RLPEncode,
12    error::RLPDecodeError,
13    structs::{Decoder, Encoder},
14};
15use serde::{Deserialize, Serialize};
16
17use super::{BYTES_PER_BLOB, CELLS_PER_EXT_BLOB, SAFE_BYTES_PER_BLOB};
18
19pub type Bytes48 = [u8; 48];
20pub type Blob = [u8; BYTES_PER_BLOB];
21pub type Commitment = Bytes48;
22pub type Proof = Bytes48;
23pub type BlobTuple = (Box<Blob>, Commitment, Vec<Proof>);
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
26#[serde(rename_all = "camelCase")]
27/// Struct containing all the blobs for a blob transaction, along with the corresponding commitments and proofs
28pub struct BlobsBundle {
29    #[serde(with = "serde_utils::blob::vec")]
30    pub blobs: Vec<Blob>,
31    #[serde(with = "serde_utils::bytes48::vec")]
32    pub commitments: Vec<Commitment>,
33    #[serde(with = "serde_utils::bytes48::vec")]
34    pub proofs: Vec<Proof>,
35    #[serde(skip, default)]
36    pub version: u8,
37}
38
39pub fn blob_from_bytes(bytes: Bytes) -> Result<Blob, BlobsBundleError> {
40    // This functions moved from `l2/utils/eth_client/transaction.rs`
41    // We set the first byte of every 32-bytes chunk to 0x00
42    // so it's always under the field module.
43    if bytes.len() > SAFE_BYTES_PER_BLOB {
44        return Err(BlobsBundleError::BlobDataInvalidBytesLength);
45    }
46
47    let mut buf = [0u8; BYTES_PER_BLOB];
48    buf[..(bytes.len() * 32).div_ceil(31)].copy_from_slice(
49        &bytes
50            .chunks(31)
51            .map(|x| [&[0x00], x].concat())
52            .collect::<Vec<_>>()
53            .concat(),
54    );
55
56    Ok(buf)
57}
58
59pub fn bytes_from_blob(blob: Bytes) -> [u8; SAFE_BYTES_PER_BLOB] {
60    let mut buf = [0u8; SAFE_BYTES_PER_BLOB];
61    buf.copy_from_slice(
62        &blob
63            .chunks(32)
64            .map(|x| x[1..].to_vec())
65            .collect::<Vec<_>>()
66            .concat(),
67    );
68
69    buf
70}
71
72pub fn kzg_commitment_to_versioned_hash(data: &Commitment) -> H256 {
73    use sha2::{Digest, Sha256};
74    let mut versioned_hash: [u8; 32] = Sha256::digest(data).into();
75    versioned_hash[0] = VERSIONED_HASH_VERSION_KZG;
76    versioned_hash.into()
77}
78
79impl BlobsBundle {
80    pub fn empty() -> Self {
81        Self::default()
82    }
83
84    pub fn is_empty(&self) -> bool {
85        self.blobs.is_empty() && self.commitments.is_empty() && self.proofs.is_empty()
86    }
87
88    // In the future we might want to provide a new method that calculates the commitments and proofs using the following.
89    #[cfg(feature = "c-kzg")]
90    pub fn create_from_blobs(
91        blobs: &Vec<Blob>,
92        wrapper_version: Option<u8>,
93    ) -> Result<Self, BlobsBundleError> {
94        use ethrex_crypto::kzg::{
95            blob_to_commitment_and_cell_proofs, blob_to_kzg_commitment_and_proof,
96        };
97        let mut commitments = Vec::new();
98        let mut proofs = Vec::new();
99
100        // Populate the commitments and proofs
101        for blob in blobs {
102            if wrapper_version.unwrap_or(0) == 0 {
103                let (commitment, proof) = blob_to_kzg_commitment_and_proof(blob)?;
104                commitments.push(commitment);
105                proofs.push(proof);
106            } else {
107                let (commitment, cell_proofs) = blob_to_commitment_and_cell_proofs(blob)?;
108                commitments.push(commitment);
109                proofs.extend(cell_proofs);
110            }
111        }
112
113        Ok(Self {
114            blobs: blobs.clone(),
115            commitments,
116            proofs,
117            version: wrapper_version.unwrap_or(0),
118        })
119    }
120
121    pub fn generate_versioned_hashes(&self) -> Vec<H256> {
122        self.commitments
123            .iter()
124            .map(kzg_commitment_to_versioned_hash)
125            .collect()
126    }
127
128    /// Given an index returns all or nothing `BlobTuple` if either of the commitment, proof or
129    /// blob is not found then it will return None instead of Partial data.
130    pub fn get_blob_tuple_by_index(&self, index: usize) -> Option<BlobTuple> {
131        let blob = Box::new(*self.blobs.get(index)?);
132        let commitment = *self.commitments.get(index)?;
133        let proofs = if self.version == 0 {
134            vec![*self.proofs.get(index)?]
135        } else {
136            self.proofs.chunks(CELLS_PER_EXT_BLOB).nth(index)?.to_vec()
137        };
138        Some((blob, commitment, proofs))
139    }
140
141    /// Full blob bundle validation: structural checks + KZG cryptographic proof verification.
142    #[cfg(feature = "c-kzg")]
143    pub fn validate(
144        &self,
145        tx: &super::EIP4844Transaction,
146        fork: super::Fork,
147    ) -> Result<(), BlobsBundleError> {
148        self.validate_cheap(tx, fork)?;
149        self.verify_kzg_proofs()
150    }
151
152    /// Verifies KZG cryptographic proofs against the blobs and commitments.
153    /// Dispatches to cell-proof or standard verification based on bundle version.
154    #[cfg(feature = "c-kzg")]
155    fn verify_kzg_proofs(&self) -> Result<(), BlobsBundleError> {
156        let valid = if self.version != 0 {
157            ethrex_crypto::kzg::verify_cell_kzg_proof_batch(
158                &self.blobs,
159                &self.commitments,
160                &self.proofs,
161            )?
162        } else {
163            ethrex_crypto::kzg::verify_kzg_proof_batch(
164                &self.blobs,
165                &self.commitments,
166                &self.proofs,
167            )?
168        };
169        if !valid {
170            return Err(BlobsBundleError::BlobToCommitmentAndProofError);
171        }
172        Ok(())
173    }
174
175    /// Validates blob bundle structure without expensive KZG cryptographic verification.
176    /// Used in P2P validation where full KZG is deferred to mempool insertion
177    /// (after dedup check), avoiding redundant proof verification for the same
178    /// blob tx received from multiple peers.
179    #[cfg(feature = "c-kzg")]
180    pub fn validate_cheap(
181        &self,
182        tx: &super::EIP4844Transaction,
183        fork: super::Fork,
184    ) -> Result<(), BlobsBundleError> {
185        use super::CELLS_PER_EXT_BLOB;
186
187        let max_blobs = max_blobs_per_block(fork);
188        let blob_count = self.blobs.len();
189
190        if blob_count > max_blobs {
191            return Err(BlobsBundleError::MaxBlobsExceeded);
192        }
193
194        // EIP-7594: a single transaction may carry at most MAX_BLOB_COUNT (6) blobs,
195        // independent of the higher per-block limit.
196        if fork >= Fork::Osaka && blob_count > MAX_BLOB_COUNT {
197            return Err(BlobsBundleError::MaxBlobsExceeded);
198        }
199
200        if blob_count == 0 {
201            return Err(BlobsBundleError::BlobBundleEmptyError);
202        }
203
204        // Blob sidecar wrapper version: 0 = EIP-4844 blob proofs, 1 = EIP-7594 cell proofs.
205        // The sidecar is a mempool/propagation-only artifact (never included in a block) and
206        // both formats are cryptographically verifiable at any fork.
207        //
208        // Acceptance criteria:
209        //   - pre-Osaka: accept BOTH v0 and v1. Peers are migrating to cell proofs at
210        //     different times (e.g. post go-ethereum#35191 geth sends v1 even pre-Osaka,
211        //     while older peers still send v0), so rejecting either would needlessly
212        //     disconnect otherwise-valid peers.
213        //   - Osaka+: only v1 (cell proofs) is valid.
214        let version_ok = if fork >= Fork::Osaka {
215            self.version == 1
216        } else {
217            self.version <= 1
218        };
219        if !version_ok {
220            return Err(BlobsBundleError::InvalidBlobVersionForFork);
221        }
222
223        if blob_count != self.commitments.len()
224            || (self.version == 0 && blob_count != self.proofs.len())
225            || (self.version != 0 && blob_count * CELLS_PER_EXT_BLOB != self.proofs.len())
226            || blob_count != tx.blob_versioned_hashes.len()
227        {
228            return Err(BlobsBundleError::BlobsBundleWrongLen);
229        };
230
231        self.validate_blob_commitment_hashes(&tx.blob_versioned_hashes)?;
232
233        Ok(())
234    }
235
236    pub fn validate_blob_commitment_hashes(
237        &self,
238        blob_versioned_hashes: &[H256],
239    ) -> Result<(), BlobsBundleError> {
240        if self.commitments.len() != blob_versioned_hashes.len() {
241            return Err(BlobsBundleError::BlobVersionedHashesError);
242        }
243        for (commitment, blob_versioned_hash) in
244            self.commitments.iter().zip(blob_versioned_hashes.iter())
245        {
246            if *blob_versioned_hash != kzg_commitment_to_versioned_hash(commitment) {
247                return Err(BlobsBundleError::BlobVersionedHashesError);
248            }
249        }
250        Ok(())
251    }
252}
253
254impl RLPEncode for BlobsBundle {
255    fn encode(&self, buf: &mut dyn bytes::BufMut) {
256        let encoder = Encoder::new(buf);
257        encoder
258            .encode_field(&self.blobs)
259            .encode_field(&self.commitments)
260            .encode_field(&self.proofs)
261            .encode_optional_field(&(self.version != 0).then_some(self.version))
262            .finish();
263    }
264}
265
266impl RLPDecode for BlobsBundle {
267    fn decode_unfinished(rlp: &[u8]) -> Result<(Self, &[u8]), RLPDecodeError> {
268        let decoder = Decoder::new(rlp)?;
269        let (blobs, decoder) = decoder.decode_field("blobs")?;
270        let (commitments, decoder) = decoder.decode_field("commitments")?;
271        let (proofs, decoder) = decoder.decode_field("proofs")?;
272        let (version, decoder) = decoder.decode_optional_field();
273        Ok((
274            Self {
275                blobs,
276                commitments,
277                proofs,
278                version: version.unwrap_or_default(),
279            },
280            decoder.finish()?,
281        ))
282    }
283}
284
285impl AddAssign for BlobsBundle {
286    fn add_assign(&mut self, rhs: Self) {
287        self.blobs.extend_from_slice(&rhs.blobs);
288        self.commitments.extend_from_slice(&rhs.commitments);
289        self.proofs.extend_from_slice(&rhs.proofs);
290    }
291}
292
293#[cfg(feature = "c-kzg")]
294const MAX_BLOB_COUNT: usize = 6;
295#[cfg(feature = "c-kzg")]
296const MAX_BLOB_COUNT_ELECTRA: usize = 9;
297
298#[cfg(feature = "c-kzg")]
299fn max_blobs_per_block(fork: crate::types::Fork) -> usize {
300    if fork >= crate::types::Fork::Prague {
301        MAX_BLOB_COUNT_ELECTRA
302    } else {
303        MAX_BLOB_COUNT
304    }
305}
306
307#[derive(Debug, thiserror::Error)]
308pub enum BlobsBundleError {
309    #[error("Blob data has an invalid length")]
310    BlobDataInvalidBytesLength,
311    #[error("Blob bundle is empty")]
312    BlobBundleEmptyError,
313    #[error("Blob versioned hashes and blobs bundle content length mismatch")]
314    BlobsBundleWrongLen,
315    #[error("Blob versioned hashes are incorrect")]
316    BlobVersionedHashesError,
317    #[error("Blob to commitment and proof generation error")]
318    BlobToCommitmentAndProofError,
319    #[error("Max blobs per block exceeded")]
320    MaxBlobsExceeded,
321    #[error("Invalid blob version for the current fork")]
322    InvalidBlobVersionForFork,
323    #[cfg(feature = "c-kzg")]
324    #[error("KZG related error: {0}")]
325    Kzg(#[from] ethrex_crypto::kzg::KzgError),
326}
327
328#[cfg(test)]
329mod tests {
330    mod shared {
331        #[cfg(feature = "c-kzg")]
332        pub fn convert_str_to_bytes48(s: &str) -> [u8; 48] {
333            let bytes = hex::decode(s).expect("Invalid hex string");
334            let mut array = [0u8; 48];
335            array.copy_from_slice(&bytes[..48]);
336            array
337        }
338    }
339
340    #[test]
341    #[cfg(feature = "c-kzg")]
342    fn transaction_with_valid_blobs_should_pass() {
343        let blobs = vec!["Hello, world!".as_bytes(), "Goodbye, world!".as_bytes()]
344            .into_iter()
345            .map(|data| {
346                crate::types::blobs_bundle::blob_from_bytes(data.into())
347                    .expect("Failed to create blob")
348            })
349            .collect();
350
351        let blobs_bundle = crate::types::BlobsBundle::create_from_blobs(&blobs, None)
352            .expect("Failed to create blobs bundle");
353
354        let blob_versioned_hashes = blobs_bundle.generate_versioned_hashes();
355
356        let tx = crate::types::transaction::EIP4844Transaction {
357            nonce: 3,
358            max_priority_fee_per_gas: 0,
359            max_fee_per_gas: 0,
360            max_fee_per_blob_gas: 0.into(),
361            gas: 15_000_000,
362            to: crate::Address::from_low_u64_be(1), // Normal tx
363            value: crate::U256::zero(),             // Value zero
364            data: crate::Bytes::default(),          // No data
365            access_list: Default::default(),        // No access list
366            blob_versioned_hashes,
367            ..Default::default()
368        };
369
370        assert!(matches!(
371            blobs_bundle.validate(&tx, crate::types::Fork::Prague),
372            Ok(())
373        ));
374    }
375
376    #[test]
377    #[cfg(feature = "c-kzg")]
378    fn transaction_with_valid_blobs_should_pass_on_osaka() {
379        let blobs = vec!["Hello, world!".as_bytes(), "Goodbye, world!".as_bytes()]
380            .into_iter()
381            .map(|data| {
382                crate::types::blobs_bundle::blob_from_bytes(data.into())
383                    .expect("Failed to create blob")
384            })
385            .collect();
386
387        let blobs_bundle = crate::types::BlobsBundle::create_from_blobs(&blobs, Some(1))
388            .expect("Failed to create blobs bundle");
389
390        let blob_versioned_hashes = blobs_bundle.generate_versioned_hashes();
391
392        let tx = crate::types::transaction::EIP4844Transaction {
393            nonce: 3,
394            max_priority_fee_per_gas: 0,
395            max_fee_per_gas: 0,
396            max_fee_per_blob_gas: 0.into(),
397            gas: 15_000_000,
398            to: crate::Address::from_low_u64_be(1), // Normal tx
399            value: crate::U256::zero(),             // Value zero
400            data: crate::Bytes::default(),          // No data
401            access_list: Default::default(),        // No access list
402            blob_versioned_hashes,
403            ..Default::default()
404        };
405
406        assert!(matches!(
407            blobs_bundle.validate(&tx, crate::types::Fork::Osaka),
408            Ok(())
409        ));
410    }
411
412    // v1 (cell-proof) sidecars are accepted pre-Osaka (network is mid-migration to cell
413    // proofs; some peers send v1 even before Osaka), but v0 (blob-proof) sidecars are
414    // rejected on Osaka+.
415    #[test]
416    #[cfg(feature = "c-kzg")]
417    fn v1_sidecar_is_accepted_pre_osaka() {
418        let blobs = vec!["Hello, world!".as_bytes(), "Goodbye, world!".as_bytes()]
419            .into_iter()
420            .map(|data| {
421                crate::types::blobs_bundle::blob_from_bytes(data.into())
422                    .expect("Failed to create blob")
423            })
424            .collect();
425
426        let blobs_bundle = crate::types::BlobsBundle::create_from_blobs(&blobs, Some(1))
427            .expect("Failed to create blobs bundle");
428
429        let blob_versioned_hashes = blobs_bundle.generate_versioned_hashes();
430
431        let tx = crate::types::transaction::EIP4844Transaction {
432            nonce: 3,
433            max_priority_fee_per_gas: 0,
434            max_fee_per_gas: 0,
435            max_fee_per_blob_gas: 0.into(),
436            gas: 15_000_000,
437            to: crate::Address::from_low_u64_be(1), // Normal tx
438            value: crate::U256::zero(),             // Value zero
439            data: crate::Bytes::default(),          // No data
440            access_list: Default::default(),        // No access list
441            blob_versioned_hashes,
442            ..Default::default()
443        };
444
445        assert!(matches!(
446            blobs_bundle.validate(&tx, crate::types::Fork::Prague),
447            Ok(())
448        ));
449    }
450
451    #[test]
452    #[cfg(feature = "c-kzg")]
453    fn v0_sidecar_is_rejected_on_osaka() {
454        let blobs = vec!["Hello, world!".as_bytes(), "Goodbye, world!".as_bytes()]
455            .into_iter()
456            .map(|data| {
457                crate::types::blobs_bundle::blob_from_bytes(data.into())
458                    .expect("Failed to create blob")
459            })
460            .collect();
461
462        let blobs_bundle = crate::types::BlobsBundle::create_from_blobs(&blobs, Some(0))
463            .expect("Failed to create blobs bundle");
464
465        let blob_versioned_hashes = blobs_bundle.generate_versioned_hashes();
466
467        let tx = crate::types::transaction::EIP4844Transaction {
468            nonce: 3,
469            max_priority_fee_per_gas: 0,
470            max_fee_per_gas: 0,
471            max_fee_per_blob_gas: 0.into(),
472            gas: 15_000_000,
473            to: crate::Address::from_low_u64_be(1), // Normal tx
474            value: crate::U256::zero(),             // Value zero
475            data: crate::Bytes::default(),          // No data
476            access_list: Default::default(),        // No access list
477            blob_versioned_hashes,
478            ..Default::default()
479        };
480
481        assert!(!matches!(
482            blobs_bundle.validate(&tx, crate::types::Fork::Osaka),
483            Ok(())
484        ));
485    }
486
487    #[test]
488    #[cfg(feature = "c-kzg")]
489    fn transaction_with_invalid_proofs_should_fail() {
490        // blob data taken from: https://etherscan.io/tx/0x02a623925c05c540a7633ffa4eb78474df826497faa81035c4168695656801a2#blobs, but with 0 size blobs
491        let blobs_bundle = crate::types::BlobsBundle {
492            blobs: vec![[0; crate::types::BYTES_PER_BLOB], [0; crate::types::BYTES_PER_BLOB]],
493            commitments: vec!["b90289aabe0fcfb8db20a76b863ba90912d1d4d040cb7a156427d1c8cd5825b4d95eaeb221124782cc216960a3d01ec5",
494                              "91189a03ce1fe1225fc5de41d502c3911c2b19596f9011ea5fca4bf311424e5f853c9c46fe026038036c766197af96a0"]
495                              .into_iter()
496                              .map(|s| {
497                                  shared::convert_str_to_bytes48(s)
498                              })
499                              .collect(),
500            proofs: vec!["b502263fc5e75b3587f4fb418e61c5d0f0c18980b4e00179326a65d082539a50c063507a0b028e2db10c55814acbe4e9",
501                         "a29c43f6d05b7f15ab6f3e5004bd5f6b190165dc17e3d51fd06179b1e42c7aef50c145750d7c1cd1cd28357593bc7658"]
502                            .into_iter()
503                            .map(|s| {
504                                shared::convert_str_to_bytes48(s)
505                            })
506                            .collect(),
507                            version: 0,
508        };
509
510        let tx = crate::types::transaction::EIP4844Transaction {
511            nonce: 3,
512            max_priority_fee_per_gas: 0,
513            max_fee_per_gas: 0,
514            max_fee_per_blob_gas: 0.into(),
515            gas: 15_000_000,
516            to: crate::Address::from_low_u64_be(1), // Normal tx
517            value: crate::U256::zero(),             // Value zero
518            data: crate::Bytes::default(),          // No data
519            access_list: Default::default(),        // No access list
520            blob_versioned_hashes: vec![
521                "01ec8054d05bfec80f49231c6e90528bbb826ccd1464c255f38004099c8918d9",
522                "0180cb2dee9e6e016fabb5da4fb208555f5145c32895ccd13b26266d558cd77d",
523            ]
524            .into_iter()
525            .map(|b| {
526                let bytes = hex::decode(b).expect("Invalid hex string");
527                crate::H256::from_slice(&bytes)
528            })
529            .collect::<Vec<crate::H256>>(),
530            ..Default::default()
531        };
532
533        assert!(matches!(
534            blobs_bundle.validate(&tx, crate::types::Fork::Prague),
535            Err(crate::types::BlobsBundleError::BlobToCommitmentAndProofError)
536        ));
537    }
538
539    #[test]
540    #[cfg(feature = "c-kzg")]
541    fn transaction_with_incorrect_blobs_should_fail() {
542        // blob data taken from: https://etherscan.io/tx/0x02a623925c05c540a7633ffa4eb78474df826497faa81035c4168695656801a2#blobs
543        let blobs_bundle = crate::types::BlobsBundle {
544            blobs: vec![[0; crate::types::BYTES_PER_BLOB], [0; crate::types::BYTES_PER_BLOB]],
545            commitments: vec!["dead89aabe0fcfb8db20a76b863ba90912d1d4d040cb7a156427d1c8cd5825b4d95eaeb221124782cc216960a3d01ec5",
546                              "91189a03ce1fe1225fc5de41d502c3911c2b19596f9011ea5fca4bf311424e5f853c9c46fe026038036c766197af96a0"]
547                              .into_iter()
548                              .map(|s| {
549                                shared::convert_str_to_bytes48(s)
550                              })
551                              .collect(),
552            proofs: vec!["b502263fc5e75b3587f4fb418e61c5d0f0c18980b4e00179326a65d082539a50c063507a0b028e2db10c55814acbe4e9",
553                         "a29c43f6d05b7f15ab6f3e5004bd5f6b190165dc17e3d51fd06179b1e42c7aef50c145750d7c1cd1cd28357593bc7658"]
554                         .into_iter()
555                              .map(|s| {
556                                shared::convert_str_to_bytes48(s)
557                              })
558                              .collect(),
559                              version: 0,
560        };
561
562        let tx = crate::types::transaction::EIP4844Transaction {
563            nonce: 3,
564            max_priority_fee_per_gas: 0,
565            max_fee_per_gas: 0,
566            max_fee_per_blob_gas: 0.into(),
567            gas: 15_000_000,
568            to: crate::Address::from_low_u64_be(1), // Normal tx
569            value: crate::U256::zero(),             // Value zero
570            data: crate::Bytes::default(),          // No data
571            access_list: Default::default(),        // No access list
572            blob_versioned_hashes: vec![
573                "01ec8054d05bfec80f49231c6e90528bbb826ccd1464c255f38004099c8918d9",
574                "0180cb2dee9e6e016fabb5da4fb208555f5145c32895ccd13b26266d558cd77d",
575            ]
576            .into_iter()
577            .map(|b| {
578                let bytes = hex::decode(b).expect("Invalid hex string");
579                crate::H256::from_slice(&bytes)
580            })
581            .collect::<Vec<crate::H256>>(),
582            ..Default::default()
583        };
584
585        assert!(matches!(
586            blobs_bundle.validate(&tx, crate::types::Fork::Prague),
587            Err(crate::types::BlobsBundleError::BlobVersionedHashesError)
588        ));
589    }
590
591    #[test]
592    #[cfg(feature = "c-kzg")]
593    fn transaction_with_too_many_blobs_should_fail() {
594        let blob = crate::types::blobs_bundle::blob_from_bytes("Im a Blob".as_bytes().into())
595            .expect("Failed to create blob");
596        let blobs =
597            std::iter::repeat_n(blob, super::MAX_BLOB_COUNT_ELECTRA + 1).collect::<Vec<_>>();
598
599        let blobs_bundle = crate::types::BlobsBundle::create_from_blobs(&blobs, None)
600            .expect("Failed to create blobs bundle");
601
602        let blob_versioned_hashes = blobs_bundle.generate_versioned_hashes();
603
604        let tx = crate::types::transaction::EIP4844Transaction {
605            nonce: 3,
606            max_priority_fee_per_gas: 0,
607            max_fee_per_gas: 0,
608            max_fee_per_blob_gas: 0.into(),
609            gas: 15_000_000,
610            to: crate::Address::from_low_u64_be(1), // Normal tx
611            value: crate::U256::zero(),             // Value zero
612            data: crate::Bytes::default(),          // No data
613            access_list: Default::default(),        // No access list
614            blob_versioned_hashes,
615            ..Default::default()
616        };
617
618        assert!(matches!(
619            blobs_bundle.validate(&tx, crate::types::Fork::Prague),
620            Err(crate::types::BlobsBundleError::MaxBlobsExceeded)
621        ));
622    }
623
624    #[test]
625    #[cfg(feature = "c-kzg")]
626    fn transaction_with_version_0_blobs_should_fail_on_amsterdam() {
627        // Version 0 blobs should be invalid on Amsterdam fork (which comes after Osaka)
628        // The validation requires version 0 only on Osaka; Amsterdam >= Osaka so version 0 is rejected
629        let blobs = vec!["Hello, world!".as_bytes(), "Goodbye, world!".as_bytes()]
630            .into_iter()
631            .map(|data| {
632                crate::types::blobs_bundle::blob_from_bytes(data.into())
633                    .expect("Failed to create blob")
634            })
635            .collect();
636
637        let blobs_bundle = crate::types::BlobsBundle::create_from_blobs(&blobs, None)
638            .expect("Failed to create blobs bundle");
639
640        let blob_versioned_hashes = blobs_bundle.generate_versioned_hashes();
641
642        let tx = crate::types::transaction::EIP4844Transaction {
643            nonce: 3,
644            max_priority_fee_per_gas: 0,
645            max_fee_per_gas: 0,
646            max_fee_per_blob_gas: 0.into(),
647            gas: 15_000_000,
648            to: crate::Address::from_low_u64_be(1), // Normal tx
649            value: crate::U256::zero(),             // Value zero
650            data: crate::Bytes::default(),          // No data
651            access_list: Default::default(),        // No access list
652            blob_versioned_hashes,
653            ..Default::default()
654        };
655
656        assert!(matches!(
657            blobs_bundle.validate(&tx, crate::types::Fork::Amsterdam),
658            Err(crate::types::BlobsBundleError::InvalidBlobVersionForFork)
659        ));
660    }
661}