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