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: °enbot_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: °enbot_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}