Skip to main content

reth_engine_tree/tree/
precompile_cache.rs

1//! Contains a precompile cache backed by `schnellru::LruMap` (LRU by length).
2
3use alloy_primitives::{
4    map::{DefaultHashBuilder, FbBuildHasher},
5    Address, Bytes,
6};
7use moka::policy::EvictionPolicy;
8use reth_evm::precompiles::{DynPrecompile, Precompile, PrecompileInput};
9use reth_primitives_traits::dashmap::DashMap;
10use revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult};
11use std::{hash::Hash, sync::Arc};
12use tracing::error;
13
14/// Default max cache size for [`PrecompileCache`]
15const MAX_CACHE_SIZE: u32 = 1024 * 1024;
16
17/// Maximum calldata size to cache for a precompile.
18const MAX_PRECOMPILE_CACHE_INPUT_SIZE: usize = 2 * 1024;
19
20/// Stores caches for each precompile.
21#[derive(Debug, Clone, Default)]
22pub struct PrecompileCacheMap<S>(Arc<DashMap<Address, PrecompileCache<S>, FbBuildHasher<20>>>)
23where
24    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static;
25
26impl<S> PrecompileCacheMap<S>
27where
28    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static,
29{
30    /// Get the precompile cache for the given address.
31    pub fn cache_for_address(&self, address: Address) -> PrecompileCache<S> {
32        // Try just using `.get` first to avoid acquiring a write lock.
33        if let Some(cache) = self.0.get(&address) {
34            return cache.clone();
35        }
36        // Otherwise, fallback to `.entry` and initialize the cache.
37        //
38        // This should be very rare as caches for all precompiles will be initialized as soon as
39        // first EVM is created.
40        self.0.entry(address).or_default().clone()
41    }
42}
43
44/// Cache for precompiles, for each input stores the result.
45#[derive(Debug, Clone)]
46pub struct PrecompileCache<S>(moka::sync::Cache<Bytes, CacheEntry<S>, DefaultHashBuilder>)
47where
48    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static;
49
50impl<S> Default for PrecompileCache<S>
51where
52    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static,
53{
54    fn default() -> Self {
55        Self(
56            moka::sync::CacheBuilder::new(MAX_CACHE_SIZE as u64)
57                .eviction_policy(EvictionPolicy::lru())
58                .weigher(|key: &Bytes, value: &CacheEntry<S>| {
59                    (key.len() + value.output.bytes.len()) as u32
60                })
61                .build_with_hasher(Default::default()),
62        )
63    }
64}
65
66impl<S> PrecompileCache<S>
67where
68    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static,
69{
70    fn get(&self, input: &[u8], spec: S) -> Option<CacheEntry<S>> {
71        self.0.get(input).filter(|e| e.spec == spec)
72    }
73
74    /// Inserts the given key and value into the cache, returning the new cache size.
75    fn insert(&self, input: Bytes, value: CacheEntry<S>) -> usize {
76        self.0.insert(input, value);
77        self.0.entry_count() as usize
78    }
79}
80
81/// Cache entry for a successful precompile output.
82///
83/// We intentionally do not cache non-successful statuses or errors.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct CacheEntry<S> {
86    output: PrecompileOutput,
87    spec: S,
88}
89
90impl<S> CacheEntry<S> {
91    const fn gas_used(&self) -> u64 {
92        self.output.gas_used
93    }
94
95    /// Converts the cache entry to a precompile result. Accepts state gas reservoir as input.
96    ///
97    /// All cached precompiles are not expected to access/created state and thus reservoir is always
98    /// kept as is.
99    fn to_precompile_result(&self, reservoir: u64) -> PrecompileResult {
100        let mut output = self.output.clone();
101        output.reservoir = reservoir;
102        Ok(output)
103    }
104}
105
106/// A cache for precompile inputs / outputs.
107#[derive(Debug)]
108pub struct CachedPrecompile<S>
109where
110    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static,
111{
112    /// Cache for precompile results and gas bounds.
113    cache: PrecompileCache<S>,
114    /// The precompile.
115    precompile: DynPrecompile,
116    /// Cache metrics.
117    metrics: Option<CachedPrecompileMetrics>,
118    /// Spec id associated to the EVM from which this cached precompile was created.
119    spec_id: S,
120}
121
122impl<S> CachedPrecompile<S>
123where
124    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static,
125{
126    /// `CachedPrecompile` constructor.
127    pub const fn new(
128        precompile: DynPrecompile,
129        cache: PrecompileCache<S>,
130        spec_id: S,
131        metrics: Option<CachedPrecompileMetrics>,
132    ) -> Self {
133        Self { precompile, cache, spec_id, metrics }
134    }
135
136    /// Wrap the given precompile in a cached precompile.
137    pub fn wrap(
138        precompile: DynPrecompile,
139        cache: PrecompileCache<S>,
140        spec_id: S,
141        metrics: Option<CachedPrecompileMetrics>,
142    ) -> DynPrecompile {
143        let precompile_id = precompile.precompile_id().clone();
144        let wrapped = Self::new(precompile, cache, spec_id, metrics);
145        (precompile_id, move |input: PrecompileInput<'_>| -> PrecompileResult {
146            wrapped.call(input)
147        })
148            .into()
149    }
150
151    fn increment_by_one_precompile_cache_hits(&self) {
152        if let Some(metrics) = &self.metrics {
153            metrics.precompile_cache_hits.increment(1);
154        }
155    }
156
157    fn increment_by_one_precompile_cache_misses(&self) {
158        if let Some(metrics) = &self.metrics {
159            metrics.precompile_cache_misses.increment(1);
160        }
161    }
162
163    fn set_precompile_cache_size_metric(&self, to: f64) {
164        if let Some(metrics) = &self.metrics {
165            metrics.precompile_cache_size.set(to);
166        }
167    }
168
169    fn increment_by_one_precompile_errors(&self) {
170        if let Some(metrics) = &self.metrics {
171            metrics.precompile_errors.increment(1);
172        }
173    }
174}
175
176impl<S> Precompile for CachedPrecompile<S>
177where
178    S: Eq + Hash + std::fmt::Debug + Send + Sync + Clone + 'static,
179{
180    fn precompile_id(&self) -> &PrecompileId {
181        self.precompile.precompile_id()
182    }
183
184    fn call(&self, input: PrecompileInput<'_>) -> PrecompileResult {
185        let cacheable_input = input.data.len() <= MAX_PRECOMPILE_CACHE_INPUT_SIZE;
186        if cacheable_input &&
187            let Some(entry) = &self.cache.get(input.data, self.spec_id.clone()) &&
188            input.gas >= entry.gas_used()
189        {
190            self.increment_by_one_precompile_cache_hits();
191            return entry.to_precompile_result(input.reservoir);
192        }
193
194        let calldata = input.data;
195        let reservoir = input.reservoir;
196        let result = self.precompile.call(input);
197
198        match &result {
199            // Only successful outputs are cacheable. Non-success statuses and errors must execute
200            // again instead of poisoning the cache for subsequent calls.
201            Ok(output) if cacheable_input && output.is_success() => {
202                // Sanity-check precompile output to ensure that it does not affect state gas in any
203                // way.
204                //
205                // This does not fully protect us from caching stateful precompiles but might make
206                // it obvious when the node is misconfigured.
207                if output.reservoir != reservoir {
208                    error!(target: "engine::tree", precompile_id = self.precompile.precompile_id().name(), "cacheable precompile decremented reservoir, skipping cache insertion");
209                } else if output.state_gas_used != 0 {
210                    error!(target: "engine::tree", precompile_id = self.precompile.precompile_id().name(), "cacheable precompile used state gas, skipping cache insertion");
211                } else {
212                    let size = self.cache.insert(
213                        Bytes::copy_from_slice(calldata),
214                        CacheEntry { output: output.clone(), spec: self.spec_id.clone() },
215                    );
216                    self.set_precompile_cache_size_metric(size as f64);
217                    self.increment_by_one_precompile_cache_misses();
218                }
219            }
220            // Oversized successful inputs execute normally but are not cacheable.
221            Ok(output) if output.is_success() => {}
222            _ => {
223                self.increment_by_one_precompile_errors();
224            }
225        }
226        result
227    }
228}
229
230/// Metrics for the cached precompile.
231#[derive(reth_metrics::Metrics, Clone)]
232#[metrics(scope = "sync.caching")]
233pub struct CachedPrecompileMetrics {
234    /// Precompile cache hits
235    pub precompile_cache_hits: metrics::Counter,
236
237    /// Precompile cache misses
238    pub precompile_cache_misses: metrics::Counter,
239
240    /// Precompile cache size. Uses the LRU cache length as the size metric.
241    pub precompile_cache_size: metrics::Gauge,
242
243    /// Precompile execution errors.
244    pub precompile_errors: metrics::Counter,
245}
246
247impl CachedPrecompileMetrics {
248    /// Creates a new instance of [`CachedPrecompileMetrics`] with the given address.
249    ///
250    /// Adds address as an `address` label padded with zeros to at least two hex symbols, prefixed
251    /// by `0x`.
252    pub fn new_with_address(address: Address) -> Self {
253        Self::new_with_labels(&[("address", format!("0x{address:02x}"))])
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use metrics_util::debugging::{DebugValue, DebuggingRecorder};
261    use reth_evm::{EthEvmFactory, Evm, EvmEnv, EvmFactory};
262    use reth_revm::db::EmptyDB;
263    use revm::{
264        context::TxEnv,
265        precompile::{PrecompileOutput, PrecompileStatus},
266        primitives::hardfork::SpecId,
267    };
268
269    #[test]
270    fn test_precompile_cache_basic() {
271        let dyn_precompile: DynPrecompile = (|_input: PrecompileInput<'_>| -> PrecompileResult {
272            Ok(PrecompileOutput {
273                status: PrecompileStatus::Success,
274                gas_used: 0,
275                state_gas_used: 0,
276                state_gas_spilled: 0,
277                reservoir: 0,
278                gas_refunded: 0,
279                bytes: Bytes::default(),
280            })
281        })
282        .into();
283
284        let cache =
285            CachedPrecompile::new(dyn_precompile, PrecompileCache::default(), SpecId::PRAGUE, None);
286
287        let output = PrecompileOutput {
288            status: PrecompileStatus::Success,
289            gas_used: 50,
290            state_gas_used: 0,
291            state_gas_spilled: 0,
292            reservoir: 0,
293            gas_refunded: 0,
294            bytes: alloy_primitives::Bytes::copy_from_slice(b"cached_result"),
295        };
296
297        let input = b"test_input";
298        let expected = CacheEntry { output, spec: SpecId::PRAGUE };
299        cache.cache.insert(input.into(), expected.clone());
300
301        let actual = cache.cache.get(input, SpecId::PRAGUE).unwrap();
302
303        assert_eq!(actual, expected);
304    }
305
306    #[test]
307    fn test_precompile_cache_map_separate_addresses() {
308        let mut evm = EthEvmFactory::default().create_evm(EmptyDB::default(), EvmEnv::default());
309        let input_data = b"same_input";
310        let gas_limit = 100_000;
311
312        let address1 = Address::repeat_byte(1);
313        let address2 = Address::repeat_byte(2);
314
315        let cache_map = PrecompileCacheMap::default();
316
317        // create the first precompile with a specific output
318        let precompile1: DynPrecompile = (PrecompileId::custom("custom"), {
319            move |input: PrecompileInput<'_>| -> PrecompileResult {
320                assert_eq!(input.data, input_data);
321
322                Ok(PrecompileOutput {
323                    status: PrecompileStatus::Success,
324                    gas_used: 5000,
325                    state_gas_used: 0,
326                    state_gas_spilled: 0,
327                    reservoir: 0,
328                    gas_refunded: 0,
329                    bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_1"),
330                })
331            }
332        })
333            .into();
334
335        // create the second precompile with a different output
336        let precompile2: DynPrecompile = (PrecompileId::custom("custom"), {
337            move |input: PrecompileInput<'_>| -> PrecompileResult {
338                assert_eq!(input.data, input_data);
339
340                Ok(PrecompileOutput {
341                    status: PrecompileStatus::Success,
342                    gas_used: 7000,
343                    state_gas_used: 0,
344                    state_gas_spilled: 0,
345                    reservoir: 0,
346                    gas_refunded: 0,
347                    bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_2"),
348                })
349            }
350        })
351            .into();
352
353        let wrapped_precompile1 = CachedPrecompile::wrap(
354            precompile1,
355            cache_map.cache_for_address(address1),
356            SpecId::PRAGUE,
357            None,
358        );
359        let wrapped_precompile2 = CachedPrecompile::wrap(
360            precompile2,
361            cache_map.cache_for_address(address2),
362            SpecId::PRAGUE,
363            None,
364        );
365
366        let precompile1_address = Address::with_last_byte(1);
367        let precompile2_address = Address::with_last_byte(2);
368
369        evm.precompiles_mut().apply_precompile(&precompile1_address, |_| Some(wrapped_precompile1));
370        evm.precompiles_mut().apply_precompile(&precompile2_address, |_| Some(wrapped_precompile2));
371
372        // first invocation of precompile1 (cache miss)
373        let result1 = evm
374            .transact_raw(TxEnv {
375                caller: Address::ZERO,
376                gas_limit,
377                data: input_data.into(),
378                kind: precompile1_address.into(),
379                ..Default::default()
380            })
381            .unwrap()
382            .result
383            .into_output()
384            .unwrap();
385        assert_eq!(result1.as_ref(), b"output_from_precompile_1");
386
387        // first invocation of precompile2 with the same input (should be a cache miss)
388        // if cache was incorrectly shared, we'd get precompile1's result
389        let result2 = evm
390            .transact_raw(TxEnv {
391                caller: Address::ZERO,
392                gas_limit,
393                data: input_data.into(),
394                kind: precompile2_address.into(),
395                ..Default::default()
396            })
397            .unwrap()
398            .result
399            .into_output()
400            .unwrap();
401        assert_eq!(result2.as_ref(), b"output_from_precompile_2");
402
403        // second invocation of precompile1 (should be a cache hit)
404        let result3 = evm
405            .transact_raw(TxEnv {
406                caller: Address::ZERO,
407                gas_limit,
408                data: input_data.into(),
409                kind: precompile1_address.into(),
410                ..Default::default()
411            })
412            .unwrap()
413            .result
414            .into_output()
415            .unwrap();
416        assert_eq!(result3.as_ref(), b"output_from_precompile_1");
417    }
418
419    #[test]
420    fn test_oversized_successful_input_is_not_an_error() {
421        let recorder = DebuggingRecorder::new();
422        let snapshotter = recorder.snapshotter();
423        let cache = PrecompileCache::default();
424        let input_data = Bytes::from(vec![0; MAX_PRECOMPILE_CACHE_INPUT_SIZE + 1]);
425        let address = Address::with_last_byte(1);
426
427        metrics::with_local_recorder(&recorder, || {
428            let precompile: DynPrecompile = (|_input: PrecompileInput<'_>| {
429                Ok(PrecompileOutput {
430                    status: PrecompileStatus::Success,
431                    gas_used: 0,
432                    state_gas_used: 0,
433                    state_gas_spilled: 0,
434                    reservoir: 0,
435                    gas_refunded: 0,
436                    bytes: Bytes::default(),
437                })
438            })
439            .into();
440            let wrapped = CachedPrecompile::wrap(
441                precompile,
442                cache.clone(),
443                SpecId::PRAGUE,
444                Some(CachedPrecompileMetrics::new_with_address(address)),
445            );
446            let mut evm =
447                EthEvmFactory::default().create_evm(EmptyDB::default(), EvmEnv::default());
448            evm.precompiles_mut().apply_precompile(&address, |_| Some(wrapped));
449
450            evm.transact_raw(TxEnv {
451                caller: Address::ZERO,
452                gas_limit: 100_000,
453                data: input_data.clone(),
454                kind: address.into(),
455                ..Default::default()
456            })
457            .unwrap();
458        });
459
460        assert!(cache.get(&input_data, SpecId::PRAGUE).is_none());
461        let error_count = snapshotter.snapshot().into_vec().into_iter().find_map(
462            |(key, _unit, _description, value)| {
463                (key.key().name() == "sync.caching.precompile_errors").then_some(value)
464            },
465        );
466        assert_eq!(error_count, Some(DebugValue::Counter(0)));
467    }
468}