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        InterpreterResult, interpreter::EthInterpreter, interpreter_types::Jumps,
15    },
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) { inputs.bytecode_address } else { inputs.target_address }
77    }
78
79    /// Derives the revert reason based on the cached data. Should only be called after a revert.
80    fn reason(&self) -> Option<DetailedRevertReason> {
81        if let Some((addr, scheme, _)) = self.non_contract_call {
82            let reason = if is_delegatecall(scheme) {
83                DetailedRevertReason::DelegateCallToNonContract(addr)
84            } else {
85                DetailedRevertReason::CallToNonContract(addr)
86            };
87
88            return Some(reason);
89        }
90
91        if let Some((addr, _)) = self.non_contract_size_check {
92            // unknown schema as the call never took place --> output most generic reason
93            return Some(DetailedRevertReason::CallToNonContract(addr));
94        }
95
96        None
97    }
98
99    /// Injects the revert diagnostic into the debug traces. Should only be called after a revert.
100    fn broadcast_diagnostic(&self, interp: &mut Interpreter) {
101        if let Some(reason) = self.reason() {
102            interp.control.instruction_result = InstructionResult::Revert;
103            interp.control.next_action = InterpreterAction::Return {
104                result: InterpreterResult {
105                    output: reason.to_string().abi_encode().into(),
106                    gas: interp.control.gas,
107                    result: InstructionResult::Revert,
108                },
109            };
110        }
111    }
112
113    /// When a `REVERT` opcode with zero data size occurs:
114    ///  - if `non_contract_call` was set at the current depth, `broadcast_diagnostic` is called.
115    ///    Otherwise, it is cleared.
116    ///  - if `non_contract_size_check` was set at the current depth, `broadcast_diagnostic` is
117    ///    called. Otherwise, it is cleared.
118    #[cold]
119    fn handle_revert<CTX, D>(&mut self, interp: &mut Interpreter, ctx: &mut CTX)
120    where
121        D: Database<Error = DatabaseError>,
122        CTX: ContextTr<Db = D>,
123        CTX::Journal: JournalExt,
124    {
125        // REVERT (offset, size)
126        if let Ok(size) = interp.stack.peek(1)
127            && size.is_zero()
128        {
129            // Check empty revert with same depth as a non-contract call
130            if let Some((_, _, depth)) = self.non_contract_call {
131                if ctx.journal_ref().depth() == depth {
132                    self.broadcast_diagnostic(interp);
133                } else {
134                    self.non_contract_call = None;
135                }
136                return;
137            }
138
139            // Check empty revert with same depth as a non-contract size check
140            if let Some((_, depth)) = self.non_contract_size_check {
141                if depth == ctx.journal_ref().depth() {
142                    self.broadcast_diagnostic(interp);
143                } else {
144                    self.non_contract_size_check = None;
145                }
146            }
147        }
148    }
149
150    /// When an `EXTCODESIZE` opcode occurs:
151    ///  - Optimistically caches the target address and current depth in `non_contract_size_check`,
152    ///    pending later validation.
153    #[cold]
154    fn handle_extcodesize<CTX, D>(&mut self, interp: &mut Interpreter, ctx: &mut CTX)
155    where
156        D: Database<Error = DatabaseError>,
157        CTX: ContextTr<Db = D>,
158        CTX::Journal: JournalExt,
159    {
160        // EXTCODESIZE (address)
161        if let Ok(word) = interp.stack.peek(0) {
162            let addr = Address::from_word(word.into());
163            if IGNORE.contains(&addr) || ctx.journal_ref().precompile_addresses().contains(&addr) {
164                return;
165            }
166
167            // Optimistically cache --> validated and cleared (if necessary) at `fn
168            // step_end()`
169            self.non_contract_size_check = Some((addr, ctx.journal_ref().depth()));
170            self.is_extcodesize_step = true;
171        }
172    }
173
174    /// Tracks `EXTCODESIZE` output. If the bytecode size is NOT 0, clears the cache.
175    #[cold]
176    fn handle_extcodesize_output(&mut self, interp: &mut Interpreter) {
177        if let Ok(size) = interp.stack.peek(0)
178            && size != U256::ZERO
179        {
180            self.non_contract_size_check = None;
181        }
182
183        self.is_extcodesize_step = false;
184    }
185}
186
187impl<CTX, D> Inspector<CTX, EthInterpreter> for RevertDiagnostic
188where
189    D: Database<Error = DatabaseError>,
190    CTX: ContextTr<Db = D>,
191    CTX::Journal: JournalExt,
192{
193    /// Tracks the first call with non-zero calldata that targets a non-contract address. Excludes
194    /// precompiles and test addresses.
195    fn call(&mut self, ctx: &mut CTX, inputs: &mut CallInputs) -> Option<CallOutcome> {
196        let target = self.code_target_address(inputs);
197
198        if IGNORE.contains(&target) || ctx.journal_ref().precompile_addresses().contains(&target) {
199            return None;
200        }
201
202        if let Ok(state) = ctx.journal().code(target)
203            && state.is_empty()
204            && !inputs.input.is_empty()
205        {
206            self.non_contract_call = Some((target, inputs.scheme, ctx.journal_ref().depth()));
207        }
208        None
209    }
210
211    /// Handles `REVERT` and `EXTCODESIZE` opcodes for diagnostics.
212    fn step(&mut self, interp: &mut Interpreter, ctx: &mut CTX) {
213        match interp.bytecode.opcode() {
214            opcode::REVERT => self.handle_revert(interp, ctx),
215            opcode::EXTCODESIZE => self.handle_extcodesize(interp, ctx),
216            _ => {}
217        }
218    }
219
220    fn step_end(&mut self, interp: &mut Interpreter, _ctx: &mut CTX) {
221        if self.is_extcodesize_step {
222            self.handle_extcodesize_output(interp);
223        }
224    }
225}