Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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;
213 changes: 213 additions & 0 deletions crates/evm/evm/src/inspectors/revert_diagnostic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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::{Cfg, ContextTr, JournalTr},
inspector::JournalExt,
interpreter::{
interpreter::EthInterpreter, interpreter_types::Jumps, CallInputs, CallOutcome, CallScheme,
InstructionResult, Interpreter, InterpreterAction, InterpreterResult,
},
precompile::{PrecompileSpecId, Precompiles},
primitives::hardfork::SpecId,
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 {
/// Checks if the `target` address is a precompile for the given `spec_id`.
fn is_precompile(&self, spec_id: SpecId, target: Address) -> bool {
let precompiles = Precompiles::new(PrecompileSpecId::from_spec_id(spec_id));
precompiles.contains(&target)
}

/// 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) || self.is_precompile(ctx.cfg().spec().into(), 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().depth()));
}
}
Copy link
Member

Choose a reason for hiding this comment

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

note: pattern CALL -> REVERT actually means that Solidity was not able to decode outputs which might happen even when contract has code but reported with invalid abi encoding like in e.g common case of ERC20 transfer not returning bool for some tokens. Ideally we should also be able to catch this case but we can track this separately

Copy link
Contributor Author

@0xrusowsky 0xrusowsky May 21, 2025

Choose a reason for hiding this comment

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

i'll open a new issue for that one and the unsupported selector.

just to be certain that i don't miss anything:

  1. missmatch between bytecode and assumed abi: we can catch it cause there is a successful call, followed by a revert at the same depth where the call took place --> to avoid false positives, we should know check the returndata validation logic, but that requires careful inspection of the opcodes following the call. any hints on what u'd do exactly?
  2. call to non supported fn selector: we can catch it (at least assuming that the target is a solidity/vyper contract) if, when a call takes places, there is a revert without any previous positive JUMPI, as that would mean that the PC walks through the whole fn dispatcher without finding a match --> using the traces decoder is limited to local contracts, but will never throw false positives. with this logic we could do everything with the inspector alone, which i like. however, do you think the approach is sound enough?

Copy link
Member

Choose a reason for hiding this comment

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

to avoid false positives, we should know check the returndata validation logic, but that requires careful inspection of the opcodes following the call. any hints on what u'd do exactly?

I think it's likely that Solidity generates some common chunk of code that's always reached in such cases. you could try writing a contract that has many such calls that are failing to decode the calldata in different methods. I'd expect all of those calls to end up in a similar REVERT opcode which we can then somehow identify and use to catch such cases

we can catch it (at least assuming that the target is a solidity/vyper contract) if, when a call takes places, there is a revert without any previous positive JUMPI, as that would mean that the PC walks through the whole fn dispatcher without finding a match

not sure if the JUMPI approach would work because iirc there are some checks around call value also, we could similarly try to see how the fallbackless contract revert looks like, I'd expect Solidity to generate similar bytecode for such

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 == U256::ZERO {
// Check empty revert with same depth as a non-contract call
if let Some((_, _, depth)) = self.non_contract_call {
if ctx.journal().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().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) || self.is_precompile(ctx.cfg().spec().into(), 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().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