Skip to main content

foundry_evm/inspectors/
revert_diagnostic.rs

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