ADR-019: In-Process revm as the Sole Simulation Executor; Strategy-vs-Engine Separation¶
Status: accepted (architecture). This ADR records a cumulative decision
reached through an architecture review
(/improve-codebase-architecture, 2026-07-20) and a subsequent grilling
session. It supersedes the “two adapters” framing implicitly carried by the
simulation crates and retires the RPC simulation surface. Some code change
ships with the acceptance; the bulk is sequenced as a multi-step refactor
(see Sequencing). The vocabulary is recorded in CONTEXT.md under
“Simulation engine vs. searcher strategy.”
Context¶
degenbot is a library consumed by many searchers with different on-chain strategies (backrun, sandwich, JIT-L, liquidation, …). The Rust core therefore owns only the constrained pieces — the in-process representation of pool/token state, the solver methods (value-only swap math), and a simulation executor — while each searcher’s transaction encoding, profit-detection strategy, and operator policy are their own code, assembled at runtime from the tools the core exposes.
Two historical facts collided to violate this separation:
The simulation crates grew a full backrun strategy.
degenbot-simulation::simulate_one(theeth_simulateV1RPC path) anddegenbot-evm::simulate_path_on_evm(the revmtransact_onepath) each carried a verbatim copy of the same backrun-example profit-detection strategy — the 3-pre-balance /execute()/ 3-post-balance vector over WETH9balanceOf/ Multicall3getEthBalance/ PoolManager ERC6909balanceOf, thedecode_balancehelper, the gross/net profit arithmetic, thecompute_priority_feemarket-aware age-decay sizing, thedispatch_profitable_resultsfan-out + categorization + thin-margin + suppression policy. This is strategy code shaped by one example bot’s funding model and executor contract (examples/eth_backrun_v2_v3_v4_rust.py+ its Vypertstore_executor.vy), not a universal simulation surface.The two crates existed as an accidental split. The shared sim primitives (
SimResult,FailBuckets,compute_priority_fee,SimulateContext,SimulatePath,BlockPriorityFees, the constants) had to pick one home and be re-exported to the other, producing a forbidden back-compat bridge (pub use degenbot_evm::{10+ symbols}) and exilingcalldatatodegenbot-evmonly to break the dependency cycle the split itself created. This is the same “split across two crates by an accidental line” pattern ADR-015 resolved for the solver seam.
The architecture review surfaced the duplication as a Candidate-A deepening
(“collapse the dual-driver 7-call orchestration behind one seam”). Grilling
then inverted that framing: the two simulate_* methods are not duplicated
engine code — they are duplicated settlement-strategy code misplaced in
engine crates. The dedup target is the strategy layer (extraction /
deletion), not the simulation core. The simulation engine itself stays
deliberately thin.
The standalone-Rust-core claim¶
A searcher building a non-backrun bot (cargo add degenbot) must not have
the backrun example’s 7-call bundle, its profit arithmetic, or its operator
policy baked into the simulation surface. Today they are — the engine crates
re-export them as the simulation interface. This strands the backrun
strategy across the future crate boundary and wedges every other strategy
into the example’s shape, contrary to AGENTS.md’s standalone-Rust-core
directive and the “no permanent Python responsibility” framing’s spirit.
Decision¶
D1 — One simulation executor: in-process revm. RPC simulation retires.¶
The sole simulation executor is the in-process revm path: BlockSimHandle
over the CacheDB<WarmCodeCache<BotStateDb<WrapDatabaseAsync<AlloyDB>>>
stack. The following retire:
degenbot-simulation::dispatch::simulate_v1— theeth_simulateV1dispatcher (a remote node executes the tx).degenbot-simulation::dispatch::create_access_list— theeth_createAccessListRPC (see D3 for the replacement).degenbot-simulation::payload—build_simulate_payload,SimulationParams,SIM_CALL_COUNT(theeth_simulateV1JSON payload is dead with no node sim).degenbot-simulation::lib::build_simulation_state_overrides— the AlloyStateOverride(stateOverridesJSON) builder. See D2.degenbot-simulation::simulate_one— the entire RPC-path orchestration. Its revm counterpart (simulate_path_on_evm/BlockSimHandle::simulate_path) is the sole surviving shape, and per D4 it moves toexamples/.The legacy
Nonearm ofdispatch_profitable_results(thebuffer_unorderedRPC fan-out). TheSome(bot_state)revm arm survives, inexamples/, collapsed to revm-only.
This realizes the long-term goal (AGENTS.md: “Rust is the engine,” in-process
sim as the production default; “minimize the use of external RPC I/O”). The
RPC surface survives only for cold-miss state fetches (AlloyDB
underneath the revm DatabaseRef stack) and the non-sim market primitive
in D5 — never for whole-transaction execution or access-list creation.
The two-adapter rule does not justify retaining an RPC-sim seam: there is one adapter (revm). Anything RPC-shaped that survives is a primitive the revm path calls underneath, not a peer simulation executor.
D2 — CacheDB insertion is the sole state-override mechanism. StateOverride retires.¶
apply_simulation_overrides in degenbot-evm/src/state_override.rs —
CacheDB::insert_account_storage / insert_account_info with the
explicit-balance-wins merge (an existing balance is preserved; only
absent balances are filled from warmup; the WETH9 balanceOf slot IS
overwritten to the operational amount) — is the sole override mechanism.
The Alloy StateOverride JSON shape and its builder retire (D1).
The override mechanism is engine-generic (backing-agnostic CacheDB
insertion; the merge discipline is a general property of CacheDB, not the
backrun example). The override params —
SimulationOverrideParams { owner, injected_address, runtime_bytecode, warmup, weth_address, pool_manager_address } + the funding amounts — are
strategy-supplied: the backrun example decides which addresses, which
slots, how much ETH; the engine renders them to CacheDB inserts.
D3 — The access list is an in-process byproduct of execution. eth_createAccessList retires.¶
The access list is collected in-realtime, as a byproduct of execute(),
via a revm Inspector attached to the first transact_one run — warmed
SLOAD/SSTORE slots/users collected through the Inspector trait hooks
(step / call / call_end, verified available on revm-inspector-41) +
the InspectEvm::inspect API. This retires:
eth_createAccessList(D1) — no remote node computes the AL.The post-re-
transactemit_access_list_from_statepath indegenbot-evm/src/access_list.rsas the primary AL source. (It remains available as an engine-generic primitive — emitting an AL from aStatejournal is a general capability — but it is no longer the production AL path; the Inspector on the first run is.)
The AL output crosses the engine→strategy seam: the engine produces the warmed-slot set via the Inspector; the strategy decides whether and how to attach it to the submitted transaction.
D4 — Backrun strategy extracts to examples/. The engine stays thin.¶
The backrun-example strategy code — the 7-call pre/post balance bundle,
decode_balance, compute_priority_fee (the TARGET_PROFIT_RATIO /
age-decay sizing), SimResult, SimulateContext, SimulatePath, FailBuckets,
the int128 guard, dispatch_profitable_results (collapsed to revm-only per
D1) and its thin-margin / suppression / categorization policy — moves out
of the engine crates to examples/ (alongside the existing
examples/eth_backrun_v2_v3_v4_rust.py driver).
No new crate is created for the strategy. examples/ enforces the
engine-vs-strategy distinction more cheaply and more honestly than a crate:
an example file is self-evidently an example, not a core surface, and it
carries no Cargo / PyO3-wrapper / standalone-reachability surface to
maintain. The dedup of the two simulate_* copies resolves by deletion
(D1 retires the RPC copy; D4 moves the revm copy once) — exactly the
extraction-not-unification correction from grilling.
The engine exposes a deliberately thin surface that the example composes from engine primitives:
BlockSimHandle::build(ctx, bot_state, warm_cache) -> handle— the per-block shared EVM.the handle’s generic per-call execution (execute a tx / a sequence of txs → per-call outcomes: status, gas, output, revert, touched state). No 7-call hardcoding, no balance-decode, no bundling.
apply_simulation_overrides(&mut cache_db, params)— the engine’s override mechanism.WarmCodeCache,BotStateDb,emit_access_list_from_state— engine internals.the Inspector-collected AL output (D3).
The example re-assembles these “more manually” (the grilling caveat): it constructs its own 7-call vector, decodes balances itself, sizes its own priority fee, runs its own fan-out policy. This is the intentional cost of not wedging all searchers into the backrun shape.
D5 — fetch_priority_fee_percentiles moves to degenbot-rpc.¶
dispatch::fetch_priority_fee_percentiles (the eth_feeHistory RPC +
parse_block_priority_fees) is a generic market-data RPC primitive (fetch
block p10/p50 priority-fee samples), not simulation logic and not strategy.
After D1–D4 it is the only surviving non-sim content of degenbot-simulation.
It moves to degenbot-rpc (the typed RPC surface crate, which already owns
AlloyProvider, EthBlock, the typed block fetchers). A sandwich or
liquidation searcher wanting the same market oracle reaches it there, not
through a backrun-shaped simulation crate.
D6 — degenbot-simulation absorbs degenbot-evm; the re-export bridge retires.¶
After D1’s retirements + D4’s extraction + D5’s leaf-move,
degenbot-simulation is empty of original content. degenbot-evm folds
in: the merged crate reuses the name degenbot-simulation (“simulation”
describes the domain; “evm” describes one implementation — naming the
umbrella after an implementation adapter is the same shallowness removed
from the code). The revm adapter + its DB stack + apply_simulation_overrides
WarmCodeCache+ the AL emitter become an internalsim/evmsubmodule of the merged crate.
The pub use degenbot_evm::{10+ symbols} re-export bridge
(degenbot-simulation/src/lib.rs) retires — it existed only because the
pipeline’s two halves were placed in two crates and had to share a type
home. AGENTS.md forbids a backwards-compatibility layer for retired
implementations; the “eventual dispatch swap” the comments cited as
justification has shipped (in-process sim is the default). The bridge
outlived its reason.
D7 — PyO3 surface decomposes into engine primitives. dispatch_profitable_py retires.¶
The PyO3 wrapper retires the monolithic dispatch_profitable_py
#[pyfunction] (and PyDispatchCandidate / PyDispatchOutcome /
PySimulateContext), which today bundles fan-out policy + suppression +
thin-margin + decode + priority-fee sizing + categorization into one opaque
Rust call from Python — exactly the shape that wedged the strategy into the
engine. In its place the engine exposes primitive #[pyfunction] /
#[pyclass] wrappers the Python driver composes: a PyBlockSimHandle
exposing build + the generic per-call execution; the override-application
primitive; fetch_priority_fee_percentiles (via D5); the AL output.
A PyO3 shell to drive the CacheDB-based override path from Python is in
scope: the recently-added apply_simulation_overrides mechanism needs the
wiring to cross the FFI so the example driver can supply
SimulationOverrideParams from Python. This wrapper is thin
(arg-extract → GIL release → core call → result wrap), per ADR-005 §3 C
and ADR-013 (the FFI seam is private).
D8 — BotStateDb forwarder: tracked debt, not decided here.¶
BotStateDb (degenbot-evm/src/bot_state_db.rs) is a confessed no-op
DatabaseRef forwarder: every method delegates to fallback; the
bot_state borrow is #[allow(dead_code)]. The deletion test passes
(complexity vanishes — routes straight to WrapDatabaseAsync<AlloyDB>).
This ADR does not collapse it: the wrapper persists as the option-B
seam (the typed-state serving path) and is explicitly tracked debt.
Tier-1 per-block shared-EVM (BlockSimHandle, ergo epic V5HCR5 —
pruned on completion) already shipped without collapsing the forwarder;
the std::dead_code allow + the unread borrow are the tell. Its
disposition — collapse to bare WrapDatabaseAsync<AlloyDB>, or wire
the typed-state serving path so the seam earns its keep — is a separate
decision gated on whether Tier-2 option B (the deferred ergo epic
L4GJEA — “cross-block CacheDB with event-driven invalidation”; its
measurement spike BF2V3B is canceled, superseded by the shipped
benchmark 5UPBGD) is ever pursued. If that option is firmly rejected,
collapsing the forwarder is cleanup; if it is pursued, BotStateDb
deepens rather than retires.
Sequencing¶
The steps land in an order chosen for independent testability — each step leaves a green, verifiable codebase:
Move
fetch_priority_fee_percentiles→degenbot-rpc(D5). Isolated leaf move; its tests move with it; zero behavior change.Wire the Inspector-based AL (D3). Additive: a new AL collection path on the first
transact_onerun, built + tested alongside the existing post-re-transactemit_access_list_from_state, then switched as primary. No behavior gap.Retire the RPC simulation surface (D1). Delete
simulate_v1,create_access_list,build_simulation_state_overrides,payload,simulate_one, and theNonearm ofdispatch_profitable_results. Step 2 already shipped the Inspector AL — the AL source is uninterrupted by the retirement. The duplication this ADR originally targeted resolves by deletion here.Merge
degenbot-evmintodegenbot-simulation(D6). After step 3 there is no strategy code left in either crate; the engine code is a cohesive set moving to one home. Tests move with their code.Extract the strategy to
examples/(D4). Move the surviving revm 7-call bundle + the revm-onlydispatch_profitable_results+compute_priority_fee+ the sim value types out of the merged engine.Decompose the PyO3 surface (D7). Retire
dispatch_profitable_py+ its pyclasses; add the primitive wrappers. Lands once the engine is in final shape (step 5).Rewire the example Python bot to compose the PyO3 primitives. The last step; depends on step 6’s surface being final.
Consequences¶
Engine surface shrinks dramatically.
degenbot-simulationexposes a thin per-call execution + override-application + AL-emission surface, not a 7-call profit-detection pipeline. A standalone Rust consumer reaching forcargo add degenbotto build a sandwich or liquidation bot is no longer forced through the backrun example’s shape.One adapter, no RPC seam. The two-adapter justification for an RPC-sim trait is gone. The simulation engine has one executor (revm); the RPC surface survives only for cold-miss state fetches + the fees oracle.
Strategy code is honestly filed. The backrun 7-call bundle, its profit arithmetic, and its operator policy live in
examples/, where their status as example code is self-evident and cannot be re-read as core. The AGENTS.md directive (“do not introduce a Python mirror of Rust-owned state … do not strand standalone-usable logic on the Python side”) is preserved: the example owns its strategy; the engine owns generic primitives.PyO3 surface is composable. A Python driver assembles engine primitives rather than calling one opaque
dispatch_profitable_py. This realizes the “give them tools they can assemble at runtime to suit their setup” intent.Performance. In-process revm is the production default and, after the Inspector-based AL (D3), executes the profit-detection run once (no post-re-transact for the AL). The cross-block
WarmCodeCache(already shipped) keeps cold-miss RPC off the per-path hot path.Dependency graph stays a DAG.
degenbot-simulation(merged) depends ondegenbot-bot(BotState),degenbot-rpc(AlloyProvider+ the D5 fees leaf),degenbot-executor(calldata + warmup-slot compute),degenbot-core(errors),degenbot-decoders(revert classification), and revm. No cycle. The no-pyo3-in-cores invariant is preserved — the strategy code inexamples/carries nopyo3, and the PyO3 surface (D7) lives indegenbot-python/src/simulation/per the three-layer discipline.Retirements are irreversible. Per AGENTS.md, no backwards-compatibility layer for retired implementations: the RPC sim path, the
StateOverridebuilder,eth_createAccessList, and the re-export bridge are deleted, not feature-gated. A consumer wanting RPC sim must re-add it; degenbot does not carry it.
Alternatives considered¶
Collapse the dual-driver 7-call pipeline behind one sim-core seam (the original Candidate A). Rejected by grilling: it would have unified the backrun strategy as the simulation core’s interface, wedging every searcher into the example’s shape. The dedup target is the strategy layer, not the engine.
A
degenbot-settlement-strategycrate. Rejected: a crate for one example doesn’t earn its Cargo / PyO3 / standalone-reachability surface.examples/enforces the distinction more cheaply.Keep the RPC sim path as a fallback (two adapters retained). Rejected: it contradicts the “minimize external RPC I/O” long-term goal and preserves a seam the two-adapter rule doesn’t justify (there is one adapter).
Keep
build_simulation_state_overridesas the engine’s RPC-rendering override path (bothStateOverrideandCacheDBinsertion in the engine). Rejected by D1/D2 — the in-process revm path is the sole executor; there is no node to send astateOverridesJSON to.Retire
eth_createAccessListbut keep the post-re-transactemit_access_list_from_stateas the AL primitive (Candidate C’s intermediate). Rejected for the production AL path: the Inspector on the first run executes the tx once (not twice) and is the actual perf win the example bot represents. The post-re-transact path remains available as an engine-generic primitive but is not the production AL source.Fold the strategy into
degenbot-botas a bundled default. Rejected: AGENTS.md’s “no permanent Python responsibility” framing could pull any code living indegenbot-bottoward “core” again, re-wedging. Code in a crate namedsettlement-strategyor underexamples/cannot accidentally be re-read as core.
References¶
CONTEXT.md— “Simulation engine vs. searcher strategy” section (the load-bearing vocabulary; kept current as the decision lands).ADR-003 (Bot as state owner) — the
BotStatethe revm DB stack reads.ADR-005 (Polars-inspired three-layer architecture) — the standalone-core constraint + the PyO3-wrapper discipline (D7’s thin shell).
ADR-013 (FFI seam is private) — D7’s PyO3 wrapper is private, no re-exported types.
ADR-015 (solver seam relocation) — the precedent for “split across two crates by an accidental line” (D6 mirrors its collapse).
ADR-018 (tracked debt) — the precedent for recording acknowledged debt without a code change (D8 follows this shape).
(former
docs/spikes/revm-composition-api-and-cold-miss-latency.md, removed in the stale-docs cleanup71ec78b2) — the revm composition API, the verifiedCacheDBinsertion surface, and the cold-miss latency profile that motivates the in-process path (D1; the RPC surface survives only for cold-miss state fetches).docs/architecture/rust-owned-bot.md— “Rust is the engine, Python is the cockpit” (the backrun bot is one cockpit, not the engine).