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