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