Skip to main content

degenbot_simulation/sim/evm/
simulator.rs

1//! The in-process simulation engine — the per-block shared EVM handle.
2//!
3//! ADR-019 D4/D7 (decision R — Rust-canonical + strategy/engine separation):
4//! this module owns the **engine** — the revm EVM handle (`BlockSimHandle`),
5//! the layered DB stack (`BlockEvm` / `ProductionBlockDb`), the
6//! `ArcDynProviderEthereum` provider newtype, and the block-env wiring + the
7//! state-override application. The **strategy** (the 7-call pre/post-balance
8//! bundle, `compute_priority_fee`, `decode_balance`, `SimResult`,
9//! `dispatch_profitable_results`, `SimulateContext`, `SimulatePath`,
10//! `FailBuckets`, the calldata builders) relocated to the
11//! `degenbot-arbitrage` crate, which drives the borrowed `&mut evm`
12//! the engine exposes via [`BlockSimHandle::evm_mut`].
13//!
14//! The engine stays generic + thin: it never names `SimulateContext` (a
15//! strategy type) — [`BlockSimHandle::build`] takes the block-env primitives
16//! (`provider`, `base_fee_next`, `current_block`, `block_timestamp`) + a
17//! projected `&SimulationOverrideParams` directly. Multiple searcher
18//! strategies (the settlement-arbitrage bundle today; sandwich/JIT-L/liquidation later) can
19//! drive the same engine with their own `SimulateContext`-equivalent config.
20//!
21//! # Per-block shared-EVM handle (Tier 1)
22//!
23//! Retires the per-path `simulate_in_process` (which rebuilt the full
24//! `CacheDB`+EVM stack per call). The per-block handle is built ONCE per block
25//! (by the strategy's `dispatch_profitable_results`) and shared (as `&mut`)
26//! across the serial fan-out. The measured shape: the trigger path pays the
27//! cold RPCs, the fan-out hits the warmed `CacheDB` at ~1 µs p50 (~50× faster
28//! than the per-path fresh-`CacheDB` config A — benchmark
29//! `examples/rpc_cache_fanout.rs`).
30//!
31//! # Correctness — shared `CacheDB` does NOT leak execute() SSTOREs
32//!
33//! revm 41 splits journalling into `transact_one` (accumulate to the journal) →
34//! `finalize` (return the `State` + CLEAR the journal; does NOT commit to the
35//! DB) → `commit` (write a `State` to the DB). The strategy's
36//! `simulate_path_on_evm` calls `transact_one` + `finalize` only — it NEVER
37//! calls `commit`. So execute()'s SSTOREs live in the per-path `State`
38//! returned by `finalize` (then discarded), NOT in the shared `CacheDB`. The
39//! `CacheDB` accumulates only READ caches (account info, bytecode, storage
40//! slots read during balanceOf/execute), which is exactly the latency win +
41//! never a stale-write hazard. (revm-handler-41/src/api.rs:44-110 is the
42//! source of truth on this split.)
43
44// Solidity/EVM identifiers (WETH9, PoolManager, ERC6909, cacheMD, databaseRef)
45// are ubiquitous here — match the degenbot-simulation convention.
46#![expect(clippy::doc_markdown)]
47use degenbot_core::op_warn;
48
49use alloy::eips::BlockId;
50use alloy::network::Ethereum;
51use alloy::primitives::U256;
52use alloy::providers::{Provider, RootProvider};
53use degenbot_bot::bot_core::SimAnchorOracle;
54use parking_lot::RwLock;
55use revm::database::CacheDB;
56use revm::database_interface::WrapDatabaseAsync;
57use std::sync::Arc;
58
59use super::state_override::SimulationOverrideParams;
60use revm::{MainBuilder, MainContext};
61
62// ─────────────────────────────────────────────────────────────────────────
63// The type-erased provider newtype (bridges `Arc<dyn Provider>` → `Provider`)
64// ─────────────────────────────────────────────────────────────────────────
65
66/// A `Provider<Ethereum>` newtype over `Arc<dyn Provider<Ethereum>>`, so the
67/// type-erased provider from [`AlloyProvider::provider_arc`](degenbot_rpc::provider::AlloyProvider::provider_arc)
68/// satisfies `P: Provider<Ethereum>` for [`revm::database::AlloyDB::new`].
69///
70/// Alloy's `Provider` trait carries `#[auto_impl::auto_impl(&, &mut, Rc, Arc,
71/// Box)]`, but the generated impl is `impl<T: Provider + Sized> Provider for
72/// Arc<T>` — it does **not** cover `?Sized` trait objects, so
73/// `Arc<dyn Provider<Ethereum>>` itself does not satisfy `P: Provider`. This
74/// newtype bridges the gap: it stores the `Arc<dyn Provider>` (always `Sized`)
75/// and impls `Provider` by delegating `root()` — the one non-default `Provider`
76/// method. Every other `Provider` method has a default impl routed through
77/// `root()`.
78#[derive(Clone)]
79pub struct ArcDynProviderEthereum(Arc<dyn Provider<Ethereum>>);
80
81impl Provider<Ethereum> for ArcDynProviderEthereum {
82    fn root(&self) -> &RootProvider<Ethereum> {
83        self.0.root()
84    }
85}
86
87// ─────────────────────────────────────────────────────────────────────────
88// The per-block shared-EVM handle (Tier 1)
89// ─────────────────────────────────────────────────────────────────────────
90
91/// The production DB stack backing a per-block [`BlockEvm`] —
92/// `CacheDB<WarmCodeCache<BotStateDb<WrapDatabaseAsync<AlloyDB<...>>>>>`.
93/// The `WarmCodeCache` layer (cross-block bytecode + account-existence, per-
94/// entry TTL'd) sits between the per-block `CacheDB` and the `BotStateDb`
95/// storage-forwarding seam; the `AlloyDB` cold-miss fallback (RPC) sits at the
96/// bottom (see [`super::warm_code_cache`]).
97pub type ProductionBlockDb<'a> = CacheDB<
98    super::WarmCodeCache<
99        super::BotStateDb<
100            'a,
101            WrapDatabaseAsync<revm::database::AlloyDB<Ethereum, ArcDynProviderEthereum>>,
102        >,
103    >,
104>;
105
106/// The frame-replay scratch stack's layered DB — the production stack with
107/// the cold-read counter ([`super::frame_replay::CountingFrameDb`]) embedded
108/// at its cold path (below the cross-block `WarmCodeCache` + the persistent
109/// ext `CacheDB`), so only true cold fetches count. See `frame_replay`.
110pub type ScratchDb<'a> = CacheDB<
111    super::WarmCodeCache<
112        super::frame_replay::CountingFrameDb<
113            super::BotStateDb<
114                'a,
115                WrapDatabaseAsync<revm::database::AlloyDB<Ethereum, ArcDynProviderEthereum>>,
116            >,
117        >,
118    >,
119>;
120
121/// The concrete per-block EVM type held by [`BlockSimHandle`] — revm's
122/// [`revm::MainnetEvm`] over the production [`ProductionBlockDb`] stack. The
123/// inspector type parameter is [`super::inspectors::SimInspector`] (a nested
124/// tuple `(AccessListCollector, (CallTraceInspector,
125/// SwapEventCaptureInspector))` — ADR-019 D3 +) — baked in so
126/// the strategy's `simulate_path_on_evm` can attach it to `execute()`'s
127/// `inspect_one` run and drain the access list + call trace + swap events.
128/// revm's blanket `Inspector` impl covers 2-tuples only, so the three-way
129/// composition is nested (`AccessListCollector` is `L`, the
130/// `CallTraceInspector`/`SwapEventCaptureInspector` pair is `R`).
131pub type BlockEvm<'a> = revm::MainnetEvm<
132    revm::handler::MainnetContext<ProductionBlockDb<'a>>,
133    super::inspectors::SimInspector,
134>;
135
136/// Owns a per-block shared EVM (one `CacheDB` + revm `Context`, state overrides
137/// applied once) for the in-process sim fan-out (Tier 1).
138///
139/// Built ONCE per block by [`BlockSimHandle::build`] (the strategy's
140/// `dispatch_profitable_results` calls it), then each candidate path is
141/// simulated SERIALLY by the strategy's `simulate_path_on_evm` over the shared
142/// `&mut evm` exposed by [`BlockSimHandle::evm_mut`]. Per-path isolation is
143/// revm's `finalize()` (clears the journal between paths — execute()'s
144/// SSTOREs live in the per-path `State` returned by `finalize`, never
145/// committed to the shared `CacheDB`; see the module-level note above). The
146/// `CacheDB` + EVM drop naturally at end of block — no cross-block caching in
147/// Tier 1, so memory is bounded by one block's working set.
148///
149/// Serial (not `buffer_unordered`) because a shared `&mut evm` can't be held
150/// across `'static`+`Send` futures. The benchmark's config B (serial-warm,
151/// ~1 µs p50) beats config A (parallel-cold, ~590 µs p50) by ~60× — losing
152/// concurrency is a NET WIN because the per-path RPC cold-miss dwarfs the
153/// per-path EVM execution.
154///
155/// The state overrides — owner ETH funding, executor code injection, warmup
156/// slots, WETH balance — are all per-block-invariant, so applying them once at
157/// [`BlockSimHandle::build`] is semantically identical to applying them
158/// per-path.
159pub struct BlockSimHandle<'a> {
160    /// The shared revm EVM. `&mut`-borrowed per path via [`evm_mut`](Self::evm_mut)
161    /// by the strategy; never aliased — the fan-out is serial.
162    evm: BlockEvm<'a>,
163    /// Build inputs retained ONLY to stack frame-replay scratch EVMs
164    /// ([`Self::scratch_evm`]) — the same shape `build` took, so a scratch is
165    /// a faithful fresh instance of the layered stack under the same per-block
166    /// env. The shared EVM itself is never re-derivable from these.
167    provider_arc: Arc<dyn Provider<Ethereum>>,
168    rpc_url: String,
169    base_fee_next: u128,
170    current_block: u64,
171    block_timestamp: u64,
172    oracle: &'a dyn SimAnchorOracle,
173    warm_cache: Arc<RwLock<super::WarmCodeCacheInner>>,
174    storage_memo: Option<std::sync::Arc<super::StorageMemo>>,
175    verify_divergence: bool,
176    /// The frame-replay scratch EVM, built lazily on the FIRST
177    /// [`Self::scratch_evm`] call and kept for the handle's life so its
178    /// warmed read-caches serve every later frame — only the per-frame DB
179    /// layer inside the scratch is discarded. `None` until first use (and
180    /// when no ambient multi-threaded runtime exists).
181    scratch: Option<super::frame_replay::ScratchEvm<super::ScratchDb<'a>>>,
182}
183
184impl<'a> BlockSimHandle<'a> {
185    /// Build the per-block shared EVM: the layered DB (`AlloyDB` →
186    /// `WrapDatabaseAsync` → `BotStateDb` → `WarmCodeCache` → `CacheDB`), the
187    /// state overrides applied ONCE (every override is per-block-invariant),
188    /// and the revm `Context` pinned to the block env. Returns `None` on a
189    /// build failure (no ambient multi-threaded runtime for
190    /// `WrapDatabaseAsync`, or an override-application error); the caller
191    /// tallies `rpc-failed` for every candidate in that case.
192    ///
193    /// The block-env primitives (`provider`, `base_fee_next`, `current_block`,
194    /// `block_timestamp`) + the projected [`SimulationOverrideParams`] are
195    /// handed in directly — the engine never names the strategy's
196    /// `SimulateContext` (ADR-019 D7, decision R — strategy/engine separation).
197    ///
198    /// `warm_cache` is the cross-block persistent bytecode + account-existence
199    /// layer ([`super::WarmCodeCache`]); it persists across blocks via the
200    /// engine owner's `Arc<RwLock<WarmCodeCacheInner>>` (cloned into the
201    /// per-block `WarmCodeCache` wrapper value here — only the inner map
202    /// survives across blocks). The per-block `CacheDB` layered above still
203    /// drops at end of block (overrides + mutable storage are per-block).
204    ///
205    /// `WrapDatabaseAsync::new` returns `None` if there is no runtime or the
206    /// runtime is `CurrentThread`. The pump drives the dispatch through
207    /// `pyo3_async_runtimes` (`Builder::new_multi_thread()`), so the `None`
208    /// path is unreachable in production — `None` means the dispatch host lost
209    /// its runtime.
210    #[expect(clippy::too_many_arguments)] // VERIFY2 T2: 9 inputs hand-build a per-block sim handle
211    #[must_use]
212    pub fn build(
213        provider: &degenbot_rpc::provider::AlloyProvider,
214        base_fee_next: u128,
215        current_block: u64,
216        block_timestamp: u64,
217        override_params: &SimulationOverrideParams,
218        oracle: &'a dyn SimAnchorOracle,
219        warm_cache: &Arc<RwLock<super::WarmCodeCacheInner>>,
220        storage_memo: Option<&std::sync::Arc<super::StorageMemo>>,
221        verify_divergence: bool,
222    ) -> Option<Self> {
223        // SIMPIPE T1 lab: build cost (the M1 amortization target — today the
224        // build re-arms per 1-candidate dispatch, M1 builds K per cycle).
225        let (built, build_dur) = super::sim_metrics::timed(|| {
226            Self::build_inner(
227                provider,
228                base_fee_next,
229                current_block,
230                block_timestamp,
231                override_params,
232                oracle,
233                warm_cache,
234                storage_memo,
235                verify_divergence,
236            )
237        });
238        super::sim_metrics::record_handle_build(build_dur);
239        built
240    }
241
242    /// The verbatim pre-lab build body (extracted for the T1 build timer).
243    #[expect(clippy::too_many_arguments)] // mirrors `build` above
244    fn build_inner(
245        provider: &degenbot_rpc::provider::AlloyProvider,
246        base_fee_next: u128,
247        current_block: u64,
248        block_timestamp: u64,
249        override_params: &SimulationOverrideParams,
250        oracle: &'a dyn SimAnchorOracle,
251        warm_cache: &Arc<RwLock<super::WarmCodeCacheInner>>,
252        storage_memo: Option<&std::sync::Arc<super::StorageMemo>>,
253        verify_divergence: bool,
254    ) -> Option<Self> {
255        // type-erased `Arc<dyn Provider>` from `provider_arc()` does NOT satisfy
256        // it (Alloy's auto-impl covers `Arc<T: Provider + Sized>`, not
257        // `?Sized` trait objects). [`ArcDynProviderEthereum`] bridges the gap.
258        let alloy_db = revm::database::AlloyDB::new(
259            ArcDynProviderEthereum(provider.provider_arc()),
260            BlockId::Number(current_block.into()),
261        );
262        let Some(wrap_db) = WrapDatabaseAsync::new(alloy_db) else {
263            op_warn!(
264                domain = sim,
265                "BlockSimHandle: no ambient multi-threaded tokio runtime — \
266                 WrapDatabaseAsync unavailable; block sim disabled"
267            );
268            return None;
269        };
270        let bot_state_db = super::BotStateDb::new_with_code_probe(
271            oracle,
272            wrap_db,
273            provider.rpc_url(),
274            current_block,
275        )
276        .with_storage_memo_opt(storage_memo)
277        .with_divergence_probe(verify_divergence);
278        let warm_code_cache =
279            super::WarmCodeCache::with_owner(Arc::clone(warm_cache), current_block, bot_state_db);
280        let mut cache_db = CacheDB::new(warm_code_cache);
281        // Apply the state-override adaptor (owner funding, executor code
282        // injection, warmup slots) over the layered DB — the same overrides the
283        // retired `eth_simulateV1` `stateOverrides` carried.
284        if let Err(err) =
285            super::state_override::apply_simulation_overrides(&mut cache_db, override_params)
286        {
287            // The override adaptor only fails on an override-application
288            // error (e.g. a warmup-slot write to an account the DB refused).
289            // The override-params are operator config, so a failure here is a
290            // wiring error, not a per-path revert — tally `rpc-failed` for
291            // every candidate (the whole block's sim is dead).
292            op_warn!(domain = sim, %err, "BlockSimHandle: state-override application failed");
293            return None;
294        }
295        let mut revm_ctx = revm::context::Context::mainnet();
296        // `disable_nonce_check`: the 7 calls share ONE owner; `eth_simulateV1`
297        // does NOT bump the caller's nonce per call (each entry is an
298        // `eth_call`-shaped read), so revm's per-tx nonce floor would reject
299        // calls [1..6]. Disable it — parity with the node's lenient simulate.
300        revm_ctx.cfg.disable_nonce_check = true;
301        let mut evm = revm_ctx
302            .with_db(cache_db)
303            .build_mainnet_with_inspector(super::inspectors::SimInspector::default());
304        evm.ctx.modify_block(|block| {
305            block.basefee = u64::try_from(base_fee_next).unwrap_or(u64::MAX);
306            block.number = U256::from(current_block);
307            // The block timestamp, threaded from the pump's block header. The
308            // default `timestamp = 1` causes V2 pair `_update` to overflow
309            // `price0CumulativeLast` in Solidity 0.8+ forks (Camelot/Aerodrome),
310            // reverting every swap — the root cause of the in-process-evm
311            // parity gap.
312            block.timestamp = U256::from(block_timestamp);
313        });
314        Some(Self {
315            evm,
316            provider_arc: provider.provider_arc(),
317            rpc_url: provider.rpc_url().to_string(),
318            base_fee_next,
319            current_block,
320            block_timestamp,
321            oracle,
322            warm_cache: Arc::clone(warm_cache),
323            storage_memo: storage_memo.cloned(),
324            verify_divergence,
325            scratch: None,
326        })
327    }
328
329    /// Borrow the shared per-block EVM mutably — the strategy's
330    /// `simulate_path_on_evm` drives this `&mut evm` per candidate path. The
331    /// fan-out is serial, so the borrow is never aliased. Per-path isolation
332    /// is revm's `finalize()` (the strategy clears the journal between paths
333    /// via the `finalize()` calls inside `simulate_path_on_evm` —
334    /// execute()'s SSTOREs stay in the per-path `State`, never committed to
335    /// the shared `CacheDB`).
336    #[must_use]
337    pub fn evm_mut(&mut self) -> &mut BlockEvm<'a> {
338        &mut self.evm
339    }
340
341    /// The frame-replay seam ([`super::frame_replay`]): a scratch engine over
342    /// a FRESHLY STACKED instance of the layered DB (same layers, new
343    /// instances — the shared EVM's journal and per-block `CacheDB` are never
344    /// aliased; the cross-block `WarmCodeCacheInner` owner arc IS shared, so
345    /// warm bytecode/account caches carry over). State overrides are NOT
346    /// applied — a foreign frame runs against chain state, not the strategy's
347    /// executor funding. Built lazily ONCE and kept for the handle's life, so
348    /// the scratch's warmed read-caches serve every later frame; only the
349    /// scratch's per-frame DB layer is discarded. The cold path is counted
350    /// (the soak-budget `rpc_reads`).
351    ///
352    /// Returns `None` when no ambient multi-threaded runtime exists for the
353    /// fresh `WrapDatabaseAsync` (same failure surface as [`Self::build`]).
354    pub fn scratch_evm(
355        &mut self,
356    ) -> Option<&mut super::frame_replay::ScratchEvm<super::ScratchDb<'a>>> {
357        if self.scratch.is_none() {
358            let alloy_db = revm::database::AlloyDB::new(
359                ArcDynProviderEthereum(Arc::clone(&self.provider_arc)),
360                BlockId::Number(self.current_block.into()),
361            );
362            let counter = std::sync::Arc::new(super::frame_replay::FrameRpcCounter::default());
363            self.scratch = WrapDatabaseAsync::new(alloy_db).map(|wrap_db| {
364                let bot_state_db = super::BotStateDb::new_with_code_probe(
365                    self.oracle,
366                    wrap_db,
367                    &self.rpc_url,
368                    self.current_block,
369                )
370                .with_storage_memo_opt(self.storage_memo.as_ref())
371                .with_divergence_probe(self.verify_divergence);
372                let counted_cold_path = super::frame_replay::CountingFrameDb::new(
373                    bot_state_db,
374                    std::sync::Arc::clone(&counter),
375                );
376                let warm_code_cache = super::WarmCodeCache::with_owner(
377                    Arc::clone(&self.warm_cache),
378                    self.current_block,
379                    counted_cold_path,
380                );
381                let ext = CacheDB::new(warm_code_cache);
382                super::frame_replay::ScratchEvm::with_counter(
383                    ext,
384                    super::frame_replay::ScratchBlock {
385                        number: self.current_block.saturating_add(1),
386                        timestamp: self.block_timestamp,
387                        base_fee_next: self.base_fee_next,
388                    },
389                    counter,
390                )
391            });
392        }
393        self.scratch.as_mut()
394    }
395}