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}