Skip to main content

degenbot_simulation/oracle/
mod.rs

1//! Contract-agnostic in-process revm **fixture driver** — deploy a pinned
2//! contract artifact, run staged calls, resolve addresses, seed storage slots,
3//! drive a call, and classify the verdict (Solidity `Revert` vs a verbless
4//! `Halt`), then read back output + logs.
5//!
6//! This is the genuinely reusable spine the tier-3 on-chain pool oracles and any
7//! **individual user investigation harness** share. It knows nothing about
8//! pools, executors, or arbitrage strategies: it only sequences EVM transactions against a
9//! fresh self-contained `CacheDB`, so a harness for *any* contract —
10//! `UniswapV3Pool`, a user's custom executor, a lending market — is a thin
11//! family-specific driver on top, not a re-derivation of the EVM plumbing.
12//!
13//! ADR-020: the tier-3 oracles deploy **canonical reference bytecode** into a
14//! revm `CacheDB` and assert the Rust math is byte-exact to it. This module is
15//! that oracle's landing zone relocated out of the test commons so it is real
16//! crate surface (`cargo add degenbot-simulation`) instead of test-only.
17//!
18//! Test/diagnostic driver only: deliberately has no FFI surface (not
19//! exposed through `degenbot._ffi`).
20
21use std::path::Path;
22
23use alloy::primitives::{keccak256, Address, Bytes, Log, U256};
24use revm::context::TxEnv;
25use revm::context_interface::result::ExecutionResult;
26pub use revm::context_interface::result::Output;
27use revm::context_interface::ContextTr;
28use revm::database::CacheDB;
29use revm::database_interface::EmptyDB;
30use revm::primitives::TxKind;
31use revm::{ExecuteCommitEvm, ExecuteEvm, MainBuilder, MainContext};
32use serde_json::Value;
33
34/// Concrete revm EVM over a self-contained empty `CacheDB` — what the fixture
35/// driver sequences calls against. Mirrors the crate's production
36/// [`evm::simulator::BlockEvm`](evm::simulator) shape on an empty DB.
37pub type FixtureEvm = revm::MainnetEvm<revm::handler::MainnetContext<CacheDB<EmptyDB>>>;
38
39/// Verdict of a single driven transaction. A real Solidity `Revert` (a
40/// math-level verdict that must be matched by the caller) is kept distinct from
41/// a verbless `Halt` (OOG / no EVM verdict), per ADR-020's H1 recurrence-rule.
42#[derive(Debug)]
43pub enum Verdict {
44    /// The transaction succeeded; the raw `Output` (for a `Create`, this is
45    /// `Output::Create(bytes, address)`; for a `Call`, `Output::Call(bytes)`)
46    /// plus the emitted logs.
47    Accepted { output: Output, logs: Vec<Log> },
48    /// Reverted via a Solidity `REVERT`; `reason` is the raw revert return-data.
49    Reverted(Bytes),
50    /// The EVM halted (OOG / invalid opcode) with no verdict.
51    Halted(String),
52}
53
54/// Build a fresh, self-contained revm EVM over an empty `CacheDB`. Callers
55/// typically build one per probe so storage starts pristine.
56#[must_use]
57pub fn new_fixture_evm() -> FixtureEvm {
58    let db = CacheDB::new(EmptyDB::default());
59    revm::context::Context::mainnet()
60        .with_db(db)
61        .build_mainnet()
62}
63
64/// Toggle revm's nonce check (the fixture driver usually wants it off so it can
65/// send arbitrary staged transactions).
66pub fn set_disable_nonce_check(evm: &mut FixtureEvm, disable: bool) {
67    evm.ctx.cfg.disable_nonce_check = disable;
68}
69
70/// Override revm's code-size limits (both contract and initcode). Pass `None`
71/// to remove the cap entirely (deploying EOF/Pancake-style oversized harnesses);
72/// `Some(n)` restores a specific cap.
73pub fn set_code_size_limits(evm: &mut FixtureEvm, max: Option<usize>) {
74    evm.ctx.cfg.limit_contract_code_size = max;
75    evm.ctx.cfg.limit_contract_initcode_size = max;
76}
77
78/// Override the block's gas limit. revm's default `BlockEnv::default()` sets
79/// `block.gas_limit` to `u64::MAX` (not a constraint), so this is usually a
80/// no-op — but it is the tx-vs-block ceiling if a caller ever narrows it.
81/// Keep it explicit for gas-limit experiments (e.g. testing a live bot's
82/// hard-coded 5M `execute()` cap against a realistic mainnet block gas limit).
83pub fn set_block_gas_limit(evm: &mut FixtureEvm, gas: u64) {
84    evm.ctx.modify_block(|block| {
85        block.gas_limit = gas;
86    });
87}
88
89/// Override revm's EIP-7825 per-tx gas-limit cap (the `tx_gas_limit_cap` cfg
90/// knob). On modern specs (Osaka+) this defaults to `TX_GAS_LIMIT_CAP`
91/// (16,777,216) and is the *binding* `TxGasLimitGreaterThanCap` ceiling for a
92/// fixture tx — independent of `block.gas_limit`. Set it to `u64::MAX` to
93/// eliminate the artificial per-tx ceiling entirely for a gas experiment.
94pub fn set_tx_gas_limit_cap(evm: &mut FixtureEvm, cap: u64) {
95    evm.ctx.cfg.tx_gas_limit_cap = Some(cap);
96}
97
98/// Execute a raw transaction (`Create` or `Call`) and classify the result.
99///
100/// The caller matches on [`Verdict`] to distinguish a math-level `Reverted`
101/// from a verbless `Halted`, exactly as the tier-3 oracles require (H1).
102///
103/// # Panics
104///
105/// Panics if the revm transaction-environment builder rejects the provided
106/// spec (a programmer error — both `Deploy` and `Call` specs are valid).
107pub fn transact(evm: &mut FixtureEvm, spec: TxSpec) -> Verdict {
108    #[expect(clippy::expect_used)] // valid tx env by construction (documented)
109    let tx = match spec {
110        TxSpec::Deploy { init_code, gas } => TxEnv::builder()
111            .kind(TxKind::Create)
112            .gas_limit(gas)
113            .data(init_code)
114            .build()
115            .expect("valid deploy tx env"),
116        TxSpec::Call { to, data, gas } => TxEnv::builder()
117            .kind(TxKind::Call(to))
118            .gas_limit(gas)
119            .data(data)
120            .build()
121            .expect("valid call tx env"),
122    };
123    match evm.transact(tx) {
124        Ok(res) => {
125            let out = match res.result {
126                ExecutionResult::Success { output, logs, .. } => {
127                    evm.commit(res.state);
128                    Verdict::Accepted { output, logs }
129                }
130                ExecutionResult::Revert { output, .. } => {
131                    evm.commit(res.state);
132                    Verdict::Reverted(output)
133                }
134                ExecutionResult::Halt { reason, .. } => {
135                    evm.commit(res.state);
136                    Verdict::Halted(format!("halted: {reason:?}"))
137                }
138            };
139            out
140        }
141        Err(e) => Verdict::Halted(format!("transact error: {e:?}")),
142    }
143}
144
145/// Execute a raw transaction like [`transact`], additionally returning the
146/// gas the transaction consumed (`res.gas_used`). Lets a probe attribute a
147/// `Halt` to out-of-gas (gas_used ≈ gas_limit) vs a genuine infeasible revert
148/// (gas_used ≪ gas_limit).
149///
150/// # Panics
151///
152/// Panics if the revm transaction-environment builder rejects the provided
153/// spec (see [`transact`]).
154pub fn transact_with_gas(evm: &mut FixtureEvm, spec: TxSpec) -> (Verdict, u64) {
155    #[expect(clippy::expect_used)] // valid tx env by construction (documented)
156    let tx = match spec {
157        TxSpec::Deploy { init_code, gas } => TxEnv::builder()
158            .kind(TxKind::Create)
159            .gas_limit(gas)
160            .data(init_code)
161            .build()
162            .expect("valid deploy tx env"),
163        TxSpec::Call { to, data, gas } => TxEnv::builder()
164            .kind(TxKind::Call(to))
165            .gas_limit(gas)
166            .data(data)
167            .build()
168            .expect("valid call tx env"),
169    };
170    match evm.transact(tx) {
171        Ok(res) => {
172            let gas_used = res.result.tx_gas_used();
173            let out = match res.result {
174                ExecutionResult::Success { output, logs, .. } => {
175                    evm.commit(res.state);
176                    Verdict::Accepted { output, logs }
177                }
178                ExecutionResult::Revert { output, .. } => {
179                    evm.commit(res.state);
180                    Verdict::Reverted(output)
181                }
182                ExecutionResult::Halt { reason, .. } => {
183                    evm.commit(res.state);
184                    Verdict::Halted(format!("halted: {reason:?}"))
185                }
186            };
187            (out, gas_used)
188        }
189        Err(e) => (Verdict::Halted(format!("transact error: {e:?}")), 0),
190    }
191}
192
193/// One transaction to sequence through [`transact`].
194pub enum TxSpec {
195    /// Contract creation from `init_code`.
196    Deploy { init_code: Bytes, gas: u64 },
197    /// A call to an existing contract.
198    Call { to: Address, data: Bytes, gas: u64 },
199}
200
201/// Deploy a contract and return its address, or a descriptive error (the
202/// harness artifact deploy failing is a fixture problem, not a verdict).
203///
204/// # Errors
205///
206/// Returns an `Err` if the deployment reverts, halts, or succeeds without
207/// yielding a `CREATE` address.
208pub fn deploy(evm: &mut FixtureEvm, init_code: Bytes, gas: u64) -> Result<Address, String> {
209    match transact(evm, TxSpec::Deploy { init_code, gas }) {
210        Verdict::Accepted {
211            output: Output::Create(_, Some(addr)),
212            ..
213        } => Ok(addr),
214        Verdict::Accepted {
215            output: Output::Create(_, None),
216            ..
217        } => Err("deploy succeeded without an address (Create output missing addr)".to_string()),
218        Verdict::Accepted { output, .. } => Err(format!(
219            "deploy succeeded with non-Create output {output:?}"
220        )),
221        Verdict::Reverted(r) => Err(format!("deploy reverted: {r:?}")),
222        Verdict::Halted(h) => Err(format!("deploy halted: {h}")),
223    }
224}
225
226/// Extract an `address` from a call that returned a 32-byte word (e.g. a Solidity
227/// `pool()` getter). Reads the trailing 20 bytes after the leading 12 zero bytes.
228///
229/// # Errors
230///
231/// Returns an `Err` if the getter call reverts/halts or returns fewer than 32
232/// bytes.
233pub fn read_address(
234    evm: &mut FixtureEvm,
235    to: Address,
236    data: Bytes,
237    gas: u64,
238) -> Result<Address, String> {
239    let out = call_bytes(evm, to, data, gas)?;
240    if out.len() < 32 {
241        return Err(format!("address getter returned {} bytes", out.len()));
242    }
243    Ok(Address::from_slice(&out.as_ref()[12..32]))
244}
245
246/// Run a call and return the raw success output bytes, or a descriptive error
247/// on revert/halt (for calls that must succeed, like setters/getters).
248///
249/// # Errors
250///
251/// Returns an `Err` if the call reverts or halts, or if it unexpectedly returns
252/// a `Create` output.
253pub fn call_bytes(
254    evm: &mut FixtureEvm,
255    to: Address,
256    data: Bytes,
257    gas: u64,
258) -> Result<Bytes, String> {
259    match transact(evm, TxSpec::Call { to, data, gas }) {
260        Verdict::Accepted {
261            output: Output::Call(b),
262            ..
263        } => Ok(b),
264        Verdict::Accepted {
265            output: Output::Create(..),
266            ..
267        } => Err("call unexpectedly returned a Create output".to_string()),
268        Verdict::Reverted(r) => Err(format!("call reverted: {r:?}")),
269        Verdict::Halted(h) => Err(format!("call halted: {h}")),
270    }
271}
272
273/// Seed one storage slot of an account in the evm's DB. `slots` are
274/// `(slot_index, value)` pairs — the raw contract storage layout, independent
275/// of any high-level pool/executor interpretation.
276pub fn seed_slots(evm: &mut FixtureEvm, account: Address, slots: &[(U256, U256)]) {
277    let db = evm.ctx.db_mut();
278    for &(slot, value) in slots {
279        db.insert_account_storage(account, slot, value).ok();
280    }
281}
282
283/// Set the native (ETH) balance of an account, preserving its code + nonce + storage.
284/// This is how the harness funds native-V4 flows (the PoolManager's holdings
285/// that back `take` of a native delta, and the executor's native seed). The
286/// plain `Token`-mint path can't mint `eth`, so native money is seeded here.
287pub fn set_native_balance(evm: &mut FixtureEvm, account: Address, balance: U256) {
288    use revm::DatabaseRef as _;
289    let db = evm.ctx.db_mut();
290    let existing = db.basic_ref(account).ok().flatten().unwrap_or_default();
291    db.insert_account_info(
292        account,
293        revm::state::AccountInfo {
294            balance,
295            ..existing
296        },
297    );
298}
299
300/// Read the native (ETH) balance of an account (the revm account balance, not
301/// an ERC-20 `balanceOf`).
302pub fn native_balance_of(evm: &mut FixtureEvm, account: Address) -> U256 {
303    use revm::DatabaseRef as _;
304    let db = evm.ctx.db_mut();
305    db.basic_ref(account)
306        .ok()
307        .flatten()
308        .map(|a| a.balance)
309        .unwrap_or_default()
310}
311
312/// First 4 bytes of `keccak256(signature)` — the Solidity function selector.
313#[must_use]
314pub fn selector(sig: &str) -> [u8; 4] {
315    let h = keccak256(sig.as_bytes());
316    let mut out = [0u8; 4];
317    out.copy_from_slice(&h.0[0..4]);
318    out
319}
320
321/// Decode a Solidity `Error(string)` revert payload to its message. Returns
322/// `None` for anything that isn't that exact ABI shape (bare reasonless revert,
323/// `Panic(uint256)`, …).
324#[must_use]
325pub fn decode_error_string(reason: &[u8]) -> Option<String> {
326    if reason.len() < 4 || reason[..4] != [0x08, 0xc3, 0x79, 0xa0] {
327        return None;
328    }
329    let data = &reason[4..];
330    if data.len() < 64 {
331        return None;
332    }
333    let len = U256::from_be_bytes::<32>(data[32..64].try_into().ok()?).to::<usize>();
334    if 64 + len > data.len() {
335        return None;
336    }
337    Some(String::from_utf8_lossy(&data[64..64 + len]).into_owned())
338}
339
340/// Extract a foundry artifact's creation bytecode (`bytecode.object`) from its
341/// JSON. The artifact is whatever was placed at the leaf `bytecode.object` in
342/// the standard foundry `out/<File>.sol/<Contract>.json` shape.
343///
344/// # Errors
345///
346/// Returns an `Err` if the JSON is malformed, lacks `bytecode.object`, or the
347/// hex fails to decode.
348pub fn parse_foundry_creation_bytecode(artifact_json: &str) -> Result<Vec<u8>, String> {
349    let v: Value =
350        serde_json::from_str(artifact_json).map_err(|e| format!("invalid artifact JSON: {e}"))?;
351    let hex_str = v["bytecode"]["object"]
352        .as_str()
353        .ok_or_else(|| "artifact has no bytecode.object".to_string())?;
354    alloy::hex::decode(hex_str.trim_start_matches("0x")).map_err(|e| format!("hex decode: {e}"))
355}
356
357/// Load a foundry build artifact from an `out/` tree at `dir`:
358/// `dir/<File>.sol/<Contract>.json`, and return its creation bytecode.
359///
360/// # Errors
361///
362/// Returns an `Err` if the file cannot be read or its creation bytecode parsed.
363pub fn load_foundry_creation_bytecode(
364    dir: &Path,
365    file: &str,
366    contract: &str,
367) -> Result<Vec<u8>, String> {
368    let path = dir.join(file).join(format!("{contract}.json"));
369    let raw = std::fs::read_to_string(&path)
370        .map_err(|e| format!("read artifact {}: {e}", path.display()))?;
371    parse_foundry_creation_bytecode(&raw)
372}