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