-
Notifications
You must be signed in to change notification settings - Fork 2.1k
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
Changes from all commits
3439f21
bdff692
381a47a
337c358
68a4577
8c571d5
bbb18a8
89e4fe8
0ab8905
832c2ac
959c713
e0c5f9e
af5e1cc
656fb2d
2a9d9ca
dfea9f0
4a606ce
ced741e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please remember that 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:
|
||
// 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these |
||
} | ||
|
||
// 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here as in |
||
if let Ok(size) = interp.stack.peek(0) { | ||
if size != U256::ZERO { | ||
self.non_contract_size_check = None; | ||
} | ||
} | ||
|
||
self.is_extcodesize_step = false; | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.