foundry_evm/inspectors/
revert_diagnostic.rs

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