Skip to main content

reth_rpc/validation/
blob_cache.rs

1//! Small cache of successfully validated builder-submission blobs.
2//!
3//! A blob versioned hash identifies its KZG commitment, not the blob bytes. EIP-7594 validation
4//! verifies that the blob's cells and proofs match that commitment. This check is independent of
5//! the block containing the blob, so a successful validation can be reused across submissions,
6//! even if the original submission later fails a separate block check.
7//!
8//! Cache hits require an exact match of the blob, commitment, and all cell proofs. Every submission
9//! still checks bundle lengths and derives versioned hashes in bundle order. Payload validation
10//! then compares those hashes with the EIP-4844 transactions in the submitted block; the cache
11//! only skips repeated KZG verification.
12
13use super::ValidationApiError;
14use alloy_consensus::EnvKzgSettings;
15use alloy_eips::eip7594::{BlobTransactionSidecarEip7594, CELLS_PER_EXT_BLOB};
16use alloy_primitives::B256;
17use alloy_rpc_types_engine::BlobsBundleV2;
18use parking_lot::Mutex;
19use std::collections::VecDeque;
20
21/// Maximum number of individual blobs retained across competing submissions.
22const VALIDATED_BLOB_CACHE_CAPACITY: usize = 15;
23
24/// Reuses KZG validation only for exact V2 blob sidecar matches.
25#[derive(Debug, Default)]
26pub(super) struct BlobValidationCache {
27    /// Every entry contains exactly one validated blob.
28    entries: Mutex<VecDeque<BlobTransactionSidecarEip7594>>,
29}
30
31impl BlobValidationCache {
32    /// Validates a V2 bundle, verifying only blobs absent from the cache.
33    pub(super) fn validate(
34        &self,
35        mut bundle: BlobsBundleV2,
36    ) -> Result<Vec<B256>, ValidationApiError> {
37        if bundle.blobs.len() != bundle.commitments.len() ||
38            bundle.blobs.len().checked_mul(CELLS_PER_EXT_BLOB) != Some(bundle.proofs.len())
39        {
40            return Err(ValidationApiError::InvalidBlobsBundle)
41        }
42        if bundle.blobs.is_empty() {
43            return Ok(Vec::new())
44        }
45
46        let versioned_hashes = bundle.versioned_hashes();
47        let hits = {
48            let entries = self.entries.lock();
49            (0..bundle.blobs.len())
50                .map(|index| {
51                    let proofs = &bundle.proofs
52                        [index * CELLS_PER_EXT_BLOB..(index + 1) * CELLS_PER_EXT_BLOB];
53                    entries.iter().any(|cached| {
54                        cached.commitments[0] == bundle.commitments[index] &&
55                            cached.cell_proofs == proofs &&
56                            cached.blobs[0] == bundle.blobs[index]
57                    })
58                })
59                .collect::<Vec<_>>()
60        };
61        if hits.iter().all(|hit| *hit) {
62            return Ok(versioned_hashes)
63        }
64
65        let mut cached = BlobsBundleV2::empty();
66        let mut missing = BlobsBundleV2::empty();
67        for hit in hits {
68            let partition = if hit { &mut cached } else { &mut missing };
69            // The checked lengths keep each blob, commitment, and proof group aligned.
70            partition.blobs.extend(bundle.blobs.drain(..1));
71            partition.commitments.extend(bundle.commitments.drain(..1));
72            partition.proofs.extend(bundle.proofs.drain(..CELLS_PER_EXT_BLOB));
73        }
74        drop(cached);
75
76        let sidecar =
77            missing.try_into_sidecar().map_err(|_| ValidationApiError::InvalidBlobsBundle)?;
78        let missing_hashes = sidecar.versioned_hashes().collect::<Vec<_>>();
79        sidecar.validate(&missing_hashes, EnvKzgSettings::default().get())?;
80
81        for index in 0..sidecar.blobs.len() {
82            let proofs =
83                &sidecar.cell_proofs[index * CELLS_PER_EXT_BLOB..(index + 1) * CELLS_PER_EXT_BLOB];
84            self.insert(BlobTransactionSidecarEip7594::new(
85                vec![sidecar.blobs[index]],
86                vec![sidecar.commitments[index]],
87                proofs.to_vec(),
88            ));
89        }
90
91        Ok(versioned_hashes)
92    }
93
94    fn insert(&self, sidecar: BlobTransactionSidecarEip7594) {
95        let mut entries = self.entries.lock();
96        if entries
97            .iter()
98            .any(|cached| cached.commitments == sidecar.commitments && cached == &sidecar)
99        {
100            return;
101        }
102        if entries.len() == VALIDATED_BLOB_CACHE_CAPACITY {
103            entries.pop_front();
104        }
105        entries.push_back(sidecar);
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use alloy_consensus::{Blob, BlobTransactionSidecar, Bytes48, SidecarBuilder, SimpleCoder};
113    use alloy_rpc_types_engine::BlobsBundleV1;
114
115    fn valid_v1_bundle(data: &[u8]) -> BlobsBundleV1 {
116        let mut builder = SidecarBuilder::<SimpleCoder>::new();
117        builder.ingest(data);
118        BlobsBundleV1::new([builder.build::<BlobTransactionSidecar>().unwrap()])
119    }
120
121    fn valid_v2_bundle(data: &[u8]) -> BlobsBundleV2 {
122        valid_v1_bundle(data).try_into_v2().unwrap()
123    }
124
125    #[test]
126    fn mixed_bundle_validates_only_missing_blobs() {
127        let cache = BlobValidationCache::default();
128        let mut bundle = valid_v2_bundle(b"first blob");
129        let second = valid_v2_bundle(b"second blob");
130        cache.validate(valid_v2_bundle(b"first blob")).unwrap();
131
132        bundle.blobs.extend(second.blobs);
133        bundle.commitments.extend(second.commitments);
134        bundle.proofs.extend(second.proofs);
135        assert_eq!(cache.validate(bundle.clone()).unwrap(), bundle.versioned_hashes());
136        assert_eq!(cache.entries.lock().len(), 2);
137    }
138
139    #[test]
140    fn v2_reuses_only_exact_validated_blobs() {
141        let cache = BlobValidationCache::default();
142        let hashes = valid_v2_bundle(b"peer das blob").versioned_hashes();
143
144        assert_eq!(cache.validate(valid_v2_bundle(b"peer das blob")).unwrap(), hashes);
145        assert_eq!(cache.validate(valid_v2_bundle(b"peer das blob")).unwrap(), hashes);
146        assert_eq!(cache.entries.lock().len(), 1);
147
148        let mut changed_blob = valid_v2_bundle(b"peer das blob");
149        changed_blob.blobs[0].0[0] ^= 1;
150        assert!(cache.validate(changed_blob).is_err());
151
152        let mut changed_proof = valid_v2_bundle(b"peer das blob");
153        changed_proof.proofs[0].0[0] ^= 1;
154        assert!(cache.validate(changed_proof).is_err());
155        assert_eq!(cache.entries.lock().len(), 1);
156    }
157
158    #[test]
159    fn validates_uncached_blob_even_when_another_hits() {
160        let cache = BlobValidationCache::default();
161        let mut bundle = valid_v2_bundle(b"first blob");
162        let mut second = valid_v2_bundle(b"second blob");
163        cache.validate(valid_v2_bundle(b"first blob")).unwrap();
164
165        second.proofs[0].0[0] ^= 1;
166        bundle.blobs.extend(second.blobs);
167        bundle.commitments.extend(second.commitments);
168        bundle.proofs.extend(second.proofs);
169        assert!(cache.validate(bundle).is_err());
170        assert_eq!(cache.entries.lock().len(), 1);
171    }
172
173    #[test]
174    fn rejects_incomplete_bundles_without_changing_cache() {
175        let cache = BlobValidationCache::default();
176        let mut v2 = valid_v2_bundle(b"blob");
177        v2.proofs.pop();
178        assert!(matches!(cache.validate(v2), Err(ValidationApiError::InvalidBlobsBundle)));
179        assert!(cache.entries.lock().is_empty());
180    }
181
182    #[test]
183    fn empty_bundle_still_checks_lengths() {
184        let cache = BlobValidationCache::default();
185        assert!(cache.validate(BlobsBundleV2::empty()).unwrap().is_empty());
186
187        let mut malformed = BlobsBundleV2::empty();
188        malformed.proofs.push(Bytes48::ZERO);
189        assert!(matches!(cache.validate(malformed), Err(ValidationApiError::InvalidBlobsBundle)));
190    }
191
192    #[test]
193    fn retains_only_fifteen_recent_blobs() {
194        let cache = BlobValidationCache::default();
195        let blob = Blob::default();
196        for index in 0..=VALIDATED_BLOB_CACHE_CAPACITY {
197            cache.insert(BlobTransactionSidecarEip7594::new(
198                vec![blob],
199                vec![Bytes48::repeat_byte(index as u8)],
200                vec![Bytes48::ZERO; CELLS_PER_EXT_BLOB],
201            ));
202        }
203
204        let entries = cache.entries.lock();
205        assert_eq!(entries.len(), VALIDATED_BLOB_CACHE_CAPACITY);
206        assert_eq!(entries.front().unwrap().commitments[0], Bytes48::repeat_byte(1));
207    }
208}