Skip to main content

foundry_evm/inspectors/
revert_diagnostic.rs

1use alloy_primitives::{Address, U256};
2use alloy_sol_types::SolValue;
3use foundry_evm_core::constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS};
4use revm::{
5    Inspector,
6    bytecode::opcode,
7    context::{ContextTr, JournalTr},
8    interpreter::{
9        CallInputs, CallOutcome, CallScheme, InstructionResult, Interpreter, InterpreterAction,
10        interpreter_types::{Jumps, LoopControl},
11    },
12};
13use std::fmt;
14
15const IGNORE: [Address; 2] = [HARDHAT_CONSOLE_ADDRESS, CHEATCODE_ADDRESS];
16
17/// Checks if the call scheme corresponds to any sort of delegate call
18pub const fn is_delegatecall(scheme: CallScheme) -> bool {
19    matches!(scheme, CallScheme::DelegateCall | CallScheme::CallCode)
20}
21
22#[derive(Debug, Clone, Copy)]
23pub enum DetailedRevertReason {
24    CallToNonContract(Address),
25    DelegateCallToNonContract(Address),
26}
27
28impl fmt::Display for DetailedRevertReason {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::CallToNonContract(addr) => {
32                write!(f, "call to non-contract address {addr}")
33            }
34            Self::DelegateCallToNonContract(addr) => write!(
35                f,
36                "delegatecall to non-contract address {addr} (usually an unliked library)"
37            ),
38        }
39    }
40}
41
42/// An inspector that tracks call context to enhances revert diagnostics.
43/// Useful for understanding reverts that are not linked to custom errors or revert strings.
44///
45/// Supported diagnostics:
46///  1. **Non-void call to non-contract address:** the soldity compiler adds some validation to the
47///     return data of the call, so despite the call succeeds, as doesn't return data, the
48///     validation causes a revert.
49///
50///     Identified when: a call with non-empty calldata is made to an address without bytecode,
51///     followed by an empty revert at the same depth.
52///
53///  2. **Void call to non-contract address:** in this case the solidity compiler adds some checks
54///     before doing the call, so it never takes place.
55///
56///     Identified when: extcodesize for the target address returns 0 + empty revert at the same
57///     depth.
58#[derive(Clone, Debug, Default)]
59pub struct RevertDiagnostic {
60    /// Tracks calls with calldata that target an address without executable code.
61    non_contract_call: Option<(Address, CallScheme, usize)>,
62    /// Tracks EXTCODESIZE checks that target an address without executable code.
63    non_contract_size_check: Option<(Address, usize)>,
64    /// Whether the step opcode is EXTCODESIZE or not.
65    is_extcodesize_step: bool,
66}
67
68impl RevertDiagnostic {
69    /// Returns the effective target address whose code would be executed.
70    /// For delegate calls, this is the `bytecode_address`. Otherwise, it's the `target_address`.
71    const fn code_target_address(&self, inputs: &mut CallInputs) -> Address {
72        if is_delegatecall(inputs.scheme) { inputs.bytecode_address } else { inputs.target_address }
73    }
74
75    /// Derives the revert reason based on the cached data. Should only be called after a revert.
76    const fn reason(&self) -> Option<DetailedRevertReason> {
77        if let Some((addr, scheme, _)) = self.non_contract_call {
78            let reason = if is_delegatecall(scheme) {
79                DetailedRevertReason::DelegateCallToNonContract(addr)
80            } else {
81                DetailedRevertReason::CallToNonContract(addr)
82            };
83
84            return Some(reason);
85        }
86
87        if let Some((addr, _)) = self.non_contract_size_check {
88            // unknown schema as the call never took place --> output most generic reason
89            return Some(DetailedRevertReason::CallToNonContract(addr));
90        }
91
92        None
93    }
94
95    /// Injects the revert diagnostic into the debug traces. Should only be called after a revert.
96    fn broadcast_diagnostic(&self, interpreter: &mut Interpreter) {
97        if let Some(reason) = self.reason() {
98            interpreter.bytecode.set_action(InterpreterAction::new_return(
99                InstructionResult::Revert,
100                reason.to_string().abi_encode().into(),
101                interpreter.gas,
102            ));
103        }
104    }
105
106    /// When a `REVERT` opcode with zero data size occurs:
107    ///  - if `non_contract_call` was set at the current depth, `broadcast_diagnostic` is called.
108    ///    Otherwise, it is cleared.
109    ///  - if `non_contract_size_check` was set at the current depth, `broadcast_diagnostic` is
110    ///    called. Otherwise, it is cleared.
111    #[cold]
112    fn handle_revert<CTX: ContextTr>(&mut self, interp: &mut Interpreter, ctx: &mut CTX) {
113        // REVERT (offset, size)
114        if let Ok(size) = interp.stack.peek(1)
115            && size.is_zero()
116        {
117            // Check empty revert with same depth as a non-contract call
118            if let Some((_, _, depth)) = self.non_contract_call {
119                if ctx.journal_ref().depth() == depth {
120                    self.broadcast_diagnostic(interp);
121                } else {
122                    self.non_contract_call = None;
123                }
124                return;
125            }
126
127            // Check empty revert with same depth as a non-contract size check
128            if let Some((_, depth)) = self.non_contract_size_check {
129                if depth == ctx.journal_ref().depth() {
130                    self.broadcast_diagnostic(interp);
131                } else {
132                    self.non_contract_size_check = None;
133                }
134            }
135        }
136    }
137
138    /// When an `EXTCODESIZE` opcode occurs:
139    ///  - Optimistically caches the target address and current depth in `non_contract_size_check`,
140    ///    pending later validation.
141    #[cold]
142    fn handle_extcodesize<CTX: ContextTr>(&mut self, interp: &mut Interpreter, ctx: &mut CTX) {
143        // EXTCODESIZE (address)
144        if let Ok(word) = interp.stack.peek(0) {
145            let addr = Address::from_word(word.into());
146            if IGNORE.contains(&addr) || ctx.journal_ref().precompile_addresses().contains(&addr) {
147                return;
148            }
149
150            // Optimistically cache --> validated and cleared (if necessary) at `fn
151            // step_end()`
152            self.non_contract_size_check = Some((addr, ctx.journal_ref().depth()));
153            self.is_extcodesize_step = true;
154        }
155    }
156
157    /// Tracks `EXTCODESIZE` output. If the bytecode size is NOT 0, clears the cache.
158    #[cold]
159    fn handle_extcodesize_output(&mut self, interp: &mut Interpreter) {
160        if let Ok(size) = interp.stack.peek(0)
161            && size != U256::ZERO
162        {
163            self.non_contract_size_check = None;
164        }
165
166        self.is_extcodesize_step = false;
167    }
168}
169
170impl<CTX: ContextTr> Inspector<CTX> for RevertDiagnostic {
171    /// Tracks the first call with non-zero calldata that targets a non-contract address. Excludes
172    /// precompiles and test addresses.
173    fn call(&mut self, ctx: &mut CTX, inputs: &mut CallInputs) -> Option<CallOutcome> {
174        let target = self.code_target_address(inputs);
175
176        if IGNORE.contains(&target) || ctx.journal_ref().precompile_addresses().contains(&target) {
177            return None;
178        }
179
180        if let Ok(state) = ctx.journal_mut().code(target)
181            && state.is_empty()
182            && !inputs.input.is_empty()
183        {
184            self.non_contract_call = Some((target, inputs.scheme, ctx.journal_ref().depth()));
185        }
186        None
187    }
188
189    /// Handles `REVERT` and `EXTCODESIZE` opcodes for diagnostics.
190    fn step(&mut self, interp: &mut Interpreter, ctx: &mut CTX) {
191        match interp.bytecode.opcode() {
192            opcode::REVERT => self.handle_revert(interp, ctx),
193            opcode::EXTCODESIZE => self.handle_extcodesize(interp, ctx),
194            _ => {}
195        }
196    }
197
198    fn step_end(&mut self, interp: &mut Interpreter, _ctx: &mut CTX) {
199        if self.is_extcodesize_step {
200            self.handle_extcodesize_output(interp);
201        }
202    }
203}