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