Skip to content

feat(forge): revert diagnostic inspector #10446

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 18 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/evm/evm/src/inspectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ pub use script::ScriptExecutionInspector;

mod stack;
pub use stack::{InspectorData, InspectorStack, InspectorStackBuilder};

mod revert_diagnostic;
pub use revert_diagnostic::RevertDiagnostic;
207 changes: 207 additions & 0 deletions crates/evm/evm/src/inspectors/revert_diagnostic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
use alloy_primitives::{Address, U256};
use alloy_sol_types::SolValue;
use foundry_evm_core::{
backend::DatabaseError,
constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS},
};
use revm::{
bytecode::opcode::{EXTCODESIZE, REVERT},
context::{ContextTr, JournalTr},
inspector::JournalExt,
interpreter::{
interpreter::EthInterpreter, interpreter_types::Jumps, CallInputs, CallOutcome, CallScheme,
InstructionResult, Interpreter, InterpreterAction, InterpreterResult,
},
Database, Inspector,
};
use std::fmt;

const IGNORE: [Address; 2] = [HARDHAT_CONSOLE_ADDRESS, CHEATCODE_ADDRESS];

/// Checks if the call scheme corresponds to any sort of delegate call
pub fn is_delegatecall(scheme: CallScheme) -> bool {
matches!(scheme, CallScheme::DelegateCall | CallScheme::ExtDelegateCall | CallScheme::CallCode)
}

#[derive(Debug, Clone, Copy)]
pub enum DetailedRevertReason {
CallToNonContract(Address),
DelegateCallToNonContract(Address),
}

impl fmt::Display for DetailedRevertReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CallToNonContract(addr) => {
write!(f, "call to non-contract address {addr}")
}
Self::DelegateCallToNonContract(addr) => write!(
f,
"delegatecall to non-contract address {addr} (usually an unliked library)"
),
}
}
}

/// An inspector that tracks call context to enhances revert diagnostics.
/// Useful for understanding reverts that are not linked to custom errors or revert strings.
///
/// Supported diagnostics:
/// 1. **Non-void call to non-contract address:** the soldity compiler adds some validation to the
/// return data of the call, so despite the call succeeds, as doesn't return data, the
/// validation causes a revert.
///
/// Identified when: a call with non-empty calldata is made to an address without bytecode,
/// followed by an empty revert at the same depth.
///
/// 2. **Void call to non-contract address:** in this case the solidity compiler adds some checks
/// before doing the call, so it never takes place.
///
/// Identified when: extcodesize for the target address returns 0 + empty revert at the same
/// depth.
#[derive(Clone, Debug, Default)]
pub struct RevertDiagnostic {
/// Tracks calls with calldata that target an address without executable code.
pub non_contract_call: Option<(Address, CallScheme, usize)>,
/// Tracks EXTCODESIZE checks that target an address without executable code.
pub non_contract_size_check: Option<(Address, usize)>,
/// Whether the step opcode is EXTCODESIZE or not.
pub is_extcodesize_step: bool,
}

impl RevertDiagnostic {
/// Returns the effective target address whose code would be executed.
/// For delegate calls, this is the `bytecode_address`. Otherwise, it's the `target_address`.
fn code_target_address(&self, inputs: &mut CallInputs) -> Address {
if is_delegatecall(inputs.scheme) {
inputs.bytecode_address
} else {
inputs.target_address
}
}

/// Derives the revert reason based on the cached data. Should only be called after a revert.
fn reason(&self) -> Option<DetailedRevertReason> {
if let Some((addr, scheme, _)) = self.non_contract_call {
let reason = if is_delegatecall(scheme) {
DetailedRevertReason::DelegateCallToNonContract(addr)
} else {
DetailedRevertReason::CallToNonContract(addr)
};

return Some(reason);
}

if let Some((addr, _)) = self.non_contract_size_check {
// unknown schema as the call never took place --> output most generic reason
return Some(DetailedRevertReason::CallToNonContract(addr));
}

None
}

/// Injects the revert diagnostic into the debug traces. Should only be called after a revert.
fn handle_revert_diagnostic(&self, interp: &mut Interpreter) {
if let Some(reason) = self.reason() {
interp.control.instruction_result = InstructionResult::Revert;
interp.control.next_action = InterpreterAction::Return {
result: InterpreterResult {
output: reason.to_string().abi_encode().into(),
gas: interp.control.gas,
result: InstructionResult::Revert,
},
};
}
}
}

impl<CTX, D> Inspector<CTX, EthInterpreter> for RevertDiagnostic
where
D: Database<Error = DatabaseError>,
CTX: ContextTr<Db = D>,
CTX::Journal: JournalExt,
{
/// Tracks the first call with non-zero calldata that targets a non-contract address. Excludes
/// precompiles and test addresses.
fn call(&mut self, ctx: &mut CTX, inputs: &mut CallInputs) -> Option<CallOutcome> {
let target = self.code_target_address(inputs);

if IGNORE.contains(&target) || ctx.journal_ref().precompile_addresses().contains(&target) {
return None;
}

if let Ok(state) = ctx.journal().code(target) {
if state.is_empty() && !inputs.input.is_empty() {
self.non_contract_call = Some((target, inputs.scheme, ctx.journal_ref().depth()));
}
}
None
}

/// Handles `REVERT` and `EXTCODESIZE` opcodes for diagnostics.
///
/// When a `REVERT` opcode with zero data size occurs:
/// - if `non_contract_call` was set at the current depth, `handle_revert_diagnostic` is
/// called. Otherwise, it is cleared.
/// - if `non_contract_call` was set at the current depth, `handle_revert_diagnostic` is
/// called. Otherwise, it is cleared.
///
/// When an `EXTCODESIZE` opcode occurs:
/// - Optimistically caches the target address and current depth in `non_contract_size_check`,
/// pending later validation.
fn step(&mut self, interp: &mut Interpreter, ctx: &mut CTX) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remember that step and step_end are INCREDIBLY hot functions, so any minimal change has a huge effect on execution performance

Please look at cheatcodes inspector on how to outline the if conditions inside of step and step_end; in this case you should do something like this:

match opcode {
    op::REVERT => self.handle_revert(...),
    op::EXTCODESIZE => self.handle_extcodesize(...),
    _ => {}
}

#[cold]
fn handle_* ...

// REVERT (offset, size)
if REVERT == interp.bytecode.opcode() {
if let Ok(size) = interp.stack.peek(1) {
if size.is_zero() {
// Check empty revert with same depth as a non-contract call
if let Some((_, _, depth)) = self.non_contract_call {
if ctx.journal_ref().depth() == depth {
self.handle_revert_diagnostic(interp);
} else {
self.non_contract_call = None;
}
return;
}

// Check empty revert with same depth as a non-contract size check
if let Some((_, depth)) = self.non_contract_size_check {
if depth == ctx.journal_ref().depth() {
self.handle_revert_diagnostic(interp);
} else {
self.non_contract_size_check = None;
}
}
}
}
}
// EXTCODESIZE (address)
else if EXTCODESIZE == interp.bytecode.opcode() {
if let Ok(word) = interp.stack.peek(0) {
let addr = Address::from_word(word.into());
if IGNORE.contains(&addr) ||
ctx.journal_ref().precompile_addresses().contains(&addr)
{
return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these return are incorrect if more checks are added in this function, outside of the if; however if you extract to separate functions as mentioned in the other comment these are fine

}

// Optimistically cache --> validated and cleared (if necessary) at `fn step_end()`
self.non_contract_size_check = Some((addr, ctx.journal_ref().depth()));
self.is_extcodesize_step = true;
}
}
}

/// Tracks `EXTCODESIZE` output. If the bytecode size is 0, clears the cache.
fn step_end(&mut self, interp: &mut Interpreter, _ctx: &mut CTX) {
if self.is_extcodesize_step {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here as in step

if let Ok(size) = interp.stack.peek(0) {
if size != U256::ZERO {
self.non_contract_size_check = None;
}
}

self.is_extcodesize_step = false;
}
}
}
40 changes: 34 additions & 6 deletions crates/evm/evm/src/inspectors/stack.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::{
Cheatcodes, CheatsConfig, ChiselState, CoverageCollector, CustomPrintTracer, Fuzzer,
LogCollector, ScriptExecutionInspector, TracingInspector,
LogCollector, RevertDiagnostic, ScriptExecutionInspector, TracingInspector,
};
use alloy_evm::{eth::EthEvmContext, Evm};
use alloy_primitives::{
Expand Down Expand Up @@ -50,7 +50,7 @@ pub struct InspectorStackBuilder {
pub cheatcodes: Option<Arc<CheatsConfig>>,
/// The fuzzer inspector and its state, if it exists.
pub fuzzer: Option<Fuzzer>,
/// Whether to enable tracing.
/// Whether to enable tracing and revert diagnostics.
pub trace_mode: TraceMode,
/// Whether logs should be collected.
pub logs: Option<bool>,
Expand Down Expand Up @@ -143,6 +143,7 @@ impl InspectorStackBuilder {
}

/// Set whether to enable the tracer.
/// Revert diagnostic inspector is activated when `mode != TraceMode::None`
#[inline]
pub fn trace_mode(mut self, mode: TraceMode) -> Self {
if self.trace_mode < mode {
Expand Down Expand Up @@ -304,6 +305,7 @@ pub struct InspectorStackInner {
pub enable_isolation: bool,
pub odyssey: bool,
pub create2_deployer: Address,
pub revert_diag: Option<RevertDiagnostic>,

/// Flag marking if we are in the inner EVM context.
pub in_inner_context: bool,
Expand Down Expand Up @@ -439,8 +441,15 @@ impl InspectorStack {
}

/// Set whether to enable the tracer.
/// Revert diagnostic inspector is activated when `mode != TraceMode::None`
#[inline]
pub fn tracing(&mut self, mode: TraceMode) {
if mode.is_none() {
self.revert_diag = None;
} else {
self.revert_diag = Some(RevertDiagnostic::default());
}

if let Some(config) = mode.into_config() {
*self.tracer.get_or_insert_with(Default::default).config_mut() = config;
} else {
Expand Down Expand Up @@ -520,7 +529,13 @@ impl InspectorStackRefMut<'_> {
let result = outcome.result.result;
call_inspectors!(
#[ret]
[&mut self.fuzzer, &mut self.tracer, &mut self.cheatcodes, &mut self.printer],
[
&mut self.fuzzer,
&mut self.tracer,
&mut self.cheatcodes,
&mut self.printer,
&mut self.revert_diag
],
|inspector| {
let previous_outcome = outcome.clone();
inspector.call_end(ecx, inputs, outcome);
Expand Down Expand Up @@ -801,7 +816,8 @@ impl Inspector<EthEvmContext<&mut dyn DatabaseExt>> for InspectorStackRefMut<'_>
&mut self.coverage,
&mut self.cheatcodes,
&mut self.script_execution_inspector,
&mut self.printer
&mut self.printer,
&mut self.revert_diag
],
|inspector| inspector.step(interpreter, ecx),
);
Expand All @@ -813,7 +829,13 @@ impl Inspector<EthEvmContext<&mut dyn DatabaseExt>> for InspectorStackRefMut<'_>
ecx: &mut EthEvmContext<&mut dyn DatabaseExt>,
) {
call_inspectors!(
[&mut self.tracer, &mut self.cheatcodes, &mut self.chisel_state, &mut self.printer],
[
&mut self.tracer,
&mut self.cheatcodes,
&mut self.chisel_state,
&mut self.printer,
&mut self.revert_diag
],
|inspector| inspector.step_end(interpreter, ecx),
);
}
Expand Down Expand Up @@ -846,7 +868,13 @@ impl Inspector<EthEvmContext<&mut dyn DatabaseExt>> for InspectorStackRefMut<'_>

call_inspectors!(
#[ret]
[&mut self.fuzzer, &mut self.tracer, &mut self.log_collector, &mut self.printer],
[
&mut self.fuzzer,
&mut self.tracer,
&mut self.log_collector,
&mut self.printer,
&mut self.revert_diag
],
|inspector| {
let mut out = None;
if let Some(output) = inspector.call(ecx, call) {
Expand Down
Loading