Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add a revert decoder to eagerly hash error selectors #7133

Merged
merged 1 commit into from
Feb 15, 2024
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
6 changes: 4 additions & 2 deletions crates/anvil/src/eth/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ use anvil_rpc::{error::RpcError, response::ResponseResult};
use foundry_common::provider::alloy::ProviderBuilder;
use foundry_evm::{
backend::DatabaseError,
decode::maybe_decode_revert,
decode::RevertDecoder,
revm::{
db::DatabaseRef,
interpreter::{return_ok, return_revert, InstructionResult},
Expand Down Expand Up @@ -1917,7 +1917,9 @@ impl EthApi {
if let Some(output) = receipt.out {
// insert revert reason if failure
if receipt.inner.status_code.unwrap_or_default().to::<u64>() == 0 {
if let Some(reason) = maybe_decode_revert(&output, None, None) {
if let Some(reason) =
RevertDecoder::new().maybe_decode(&output, None)
{
tx.other.insert(
"revertReason".to_string(),
serde_json::to_value(reason).expect("Infallible"),
Expand Down
7 changes: 3 additions & 4 deletions crates/anvil/src/eth/backend/mem/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ use foundry_common::types::ToAlloy;
use foundry_evm::{
backend::{DatabaseError, DatabaseResult, RevertSnapshotAction},
constants::DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE,
decode::decode_revert,
decode::RevertDecoder,
inspectors::AccessListTracer,
revm::{
self,
Expand Down Expand Up @@ -956,9 +956,8 @@ impl Backend {
}
node_info!(" Gas used: {}", receipt.gas_used());
if !info.exit.is_ok() {
let r = decode_revert(
info.out.clone().unwrap_or_default().as_ref(),
None,
let r = RevertDecoder::new().decode(
info.out.as_ref().map(|b| &b[..]).unwrap_or_default(),
Some(info.exit),
);
node_info!(" Error: reverted with: {r}");
Expand Down
7 changes: 4 additions & 3 deletions crates/anvil/src/eth/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use anvil_rpc::{
};
use foundry_evm::{
backend::DatabaseError,
decode::maybe_decode_revert,
decode::RevertDecoder,
revm::{
self,
interpreter::InstructionResult,
Expand Down Expand Up @@ -302,8 +302,9 @@ impl<T: Serialize> ToRpcResponseResult for Result<T> {
InvalidTransactionError::Revert(data) => {
// this mimics geth revert error
let mut msg = "execution reverted".to_string();
if let Some(reason) =
data.as_ref().and_then(|data| maybe_decode_revert(data, None, None))
if let Some(reason) = data
.as_ref()
.and_then(|data| RevertDecoder::new().maybe_decode(data, None))
{
msg = format!("{msg}: {reason}");
}
Expand Down
11 changes: 0 additions & 11 deletions crates/common/src/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,6 @@ impl ContractsByArtifact {
}
(funcs, events, errors_abi)
}

/// Flattens the errors into a single JsonAbi.
pub fn flatten_errors(&self) -> JsonAbi {
let mut errors_abi = JsonAbi::new();
for (_name, (abi, _code)) in self.iter() {
for error in abi.errors() {
errors_abi.errors.entry(error.name.clone()).or_default().push(error.clone());
}
}
errors_abi
}
}

impl Deref for ContractsByArtifact {
Expand Down
231 changes: 145 additions & 86 deletions crates/evm/core/src/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

use crate::abi::{Console, Vm};
use alloy_dyn_abi::JsonAbiExt;
use alloy_json_abi::JsonAbi;
use alloy_primitives::Log;
use alloy_json_abi::{Error, JsonAbi};
use alloy_primitives::{Log, Selector};
use alloy_sol_types::{SolCall, SolError, SolEventInterface, SolInterface, SolValue};
use foundry_common::SELECTOR_LEN;
use itertools::Itertools;
use revm::interpreter::InstructionResult;
use std::{collections::HashMap, sync::OnceLock};

/// Decode a set of logs, only returning logs from DSTest logging events and Hardhat's `console.log`
pub fn decode_console_logs(logs: &[Log]) -> Vec<String> {
Expand All @@ -23,107 +24,165 @@ pub fn decode_console_log(log: &Log) -> Option<String> {
Console::ConsoleEvents::decode_log(log, false).ok().map(|decoded| decoded.to_string())
}

/// Tries to decode an error message from the given revert bytes.
///
/// Note that this is just a best-effort guess, and should not be relied upon for anything other
/// than user output.
pub fn decode_revert(
err: &[u8],
maybe_abi: Option<&JsonAbi>,
status: Option<InstructionResult>,
) -> String {
maybe_decode_revert(err, maybe_abi, status).unwrap_or_else(|| {
if err.is_empty() {
"<empty revert data>".to_string()
} else {
trimmed_hex(err)
}
})
/// Decodes revert data.
#[derive(Clone, Debug, Default)]
pub struct RevertDecoder {
/// The custom errors to use for decoding.
pub errors: HashMap<Selector, Vec<Error>>,
}

/// Tries to decode an error message from the given revert bytes.
///
/// See [`decode_revert`] for more information.
pub fn maybe_decode_revert(
err: &[u8],
maybe_abi: Option<&JsonAbi>,
status: Option<InstructionResult>,
) -> Option<String> {
if err.len() < SELECTOR_LEN {
if let Some(status) = status {
if !status.is_ok() {
return Some(format!("EvmError: {status:?}"));
}
}
return if err.is_empty() {
None
} else {
Some(format!("custom error bytes {}", hex::encode_prefixed(err)))
};
impl Default for &RevertDecoder {
fn default() -> Self {
static EMPTY: OnceLock<RevertDecoder> = OnceLock::new();
EMPTY.get_or_init(RevertDecoder::new)
}
}

if err == crate::constants::MAGIC_SKIP {
// Also used in forge fuzz runner
return Some("SKIPPED".to_string());
impl RevertDecoder {
/// Creates a new, empty revert decoder.
pub fn new() -> Self {
Self::default()
}

// Solidity's `Error(string)` or `Panic(uint256)`
if let Ok(e) = alloy_sol_types::GenericContractError::abi_decode(err, false) {
return Some(e.to_string());
/// Sets the ABIs to use for error decoding.
///
/// Note that this is decently expensive as it will hash all errors for faster indexing.
pub fn with_abis<'a>(mut self, abi: impl IntoIterator<Item = &'a JsonAbi>) -> Self {
self.extend_from_abis(abi);
self
}

let (selector, data) = err.split_at(SELECTOR_LEN);
let selector: &[u8; 4] = selector.try_into().unwrap();
/// Sets the ABI to use for error decoding.
///
/// Note that this is decently expensive as it will hash all errors for faster indexing.
pub fn with_abi(mut self, abi: &JsonAbi) -> Self {
self.extend_from_abi(abi);
self
}

match *selector {
// `CheatcodeError(string)`
Vm::CheatcodeError::SELECTOR => {
let e = Vm::CheatcodeError::abi_decode_raw(data, false).ok()?;
return Some(e.message);
/// Sets the ABI to use for error decoding, if it is present.
///
/// Note that this is decently expensive as it will hash all errors for faster indexing.
pub fn with_abi_opt(mut self, abi: Option<&JsonAbi>) -> Self {
if let Some(abi) = abi {
self.extend_from_abi(abi);
}
// `expectRevert(bytes)`
Vm::expectRevert_2Call::SELECTOR => {
let e = Vm::expectRevert_2Call::abi_decode_raw(data, false).ok()?;
return maybe_decode_revert(&e.revertData[..], maybe_abi, status);
}
// `expectRevert(bytes4)`
Vm::expectRevert_1Call::SELECTOR => {
let e = Vm::expectRevert_1Call::abi_decode_raw(data, false).ok()?;
return maybe_decode_revert(&e.revertData[..], maybe_abi, status);
self
}

/// Extends the decoder with the given ABI's custom errors.
pub fn extend_from_abis<'a>(&mut self, abi: impl IntoIterator<Item = &'a JsonAbi>) {
for abi in abi {
self.extend_from_abi(abi);
}
_ => {}
}

// Custom error from the given ABI
if let Some(abi) = maybe_abi {
if let Some(abi_error) = abi.errors().find(|e| selector == e.selector()) {
// if we don't decode, don't return an error, try to decode as a string later
if let Ok(decoded) = abi_error.abi_decode_input(data, false) {
return Some(format!(
"{}({})",
abi_error.name,
decoded.iter().map(foundry_common::fmt::format_token).format(", ")
));
}
/// Extends the decoder with the given ABI's custom errors.
pub fn extend_from_abi(&mut self, abi: &JsonAbi) {
for error in abi.errors() {
self.push_error(error.clone());
}
}

// ABI-encoded `string`
if let Ok(s) = String::abi_decode(err, false) {
return Some(s);
/// Adds a custom error to use for decoding.
pub fn push_error(&mut self, error: Error) {
self.errors.entry(error.selector()).or_default().push(error);
}

// UTF-8-encoded string
if let Ok(s) = std::str::from_utf8(err) {
return Some(s.to_string());
/// Tries to decode an error message from the given revert bytes.
///
/// Note that this is just a best-effort guess, and should not be relied upon for anything other
/// than user output.
pub fn decode(&self, err: &[u8], status: Option<InstructionResult>) -> String {
self.maybe_decode(err, status).unwrap_or_else(|| {
if err.is_empty() {
"<empty revert data>".to_string()
} else {
trimmed_hex(err)
}
})
}

// Generic custom error
Some(format!(
"custom error {}:{}",
hex::encode(selector),
std::str::from_utf8(data).map_or_else(|_| trimmed_hex(data), String::from)
))
/// Tries to decode an error message from the given revert bytes.
///
/// See [`decode_revert`] for more information.
pub fn maybe_decode(&self, err: &[u8], status: Option<InstructionResult>) -> Option<String> {
if err.len() < SELECTOR_LEN {
if let Some(status) = status {
if !status.is_ok() {
return Some(format!("EvmError: {status:?}"));
}
}
return if err.is_empty() {
None
} else {
Some(format!("custom error bytes {}", hex::encode_prefixed(err)))
};
}

if err == crate::constants::MAGIC_SKIP {
// Also used in forge fuzz runner
return Some("SKIPPED".to_string());
}

// Solidity's `Error(string)` or `Panic(uint256)`
if let Ok(e) = alloy_sol_types::GenericContractError::abi_decode(err, false) {
return Some(e.to_string());
}

let (selector, data) = err.split_at(SELECTOR_LEN);
let selector: &[u8; 4] = selector.try_into().unwrap();

match *selector {
// `CheatcodeError(string)`
Vm::CheatcodeError::SELECTOR => {
let e = Vm::CheatcodeError::abi_decode_raw(data, false).ok()?;
return Some(e.message);
}
// `expectRevert(bytes)`
Vm::expectRevert_2Call::SELECTOR => {
let e = Vm::expectRevert_2Call::abi_decode_raw(data, false).ok()?;
return self.maybe_decode(&e.revertData[..], status);
}
// `expectRevert(bytes4)`
Vm::expectRevert_1Call::SELECTOR => {
let e = Vm::expectRevert_1Call::abi_decode_raw(data, false).ok()?;
return self.maybe_decode(&e.revertData[..], status);
}
_ => {}
}

// Custom errors.
if let Some(errors) = self.errors.get(selector) {
for error in errors {
// If we don't decode, don't return an error, try to decode as a string later.
if let Ok(decoded) = error.abi_decode_input(data, false) {
return Some(format!(
"{}({})",
error.name,
decoded.iter().map(foundry_common::fmt::format_token).format(", ")
));
}
}
}

// ABI-encoded `string`.
if let Ok(s) = String::abi_decode(err, false) {
return Some(s);
}

// UTF-8-encoded string.
if let Ok(s) = std::str::from_utf8(err) {
return Some(s.to_string());
}

// Generic custom error.
Some(format!(
"custom error {}:{}",
hex::encode(selector),
std::str::from_utf8(data).map_or_else(|_| trimmed_hex(data), String::from)
))
}
}

fn trimmed_hex(s: &[u8]) -> String {
Expand All @@ -147,8 +206,8 @@ mod tests {
fn test_trimmed_hex() {
assert_eq!(trimmed_hex(&hex::decode("1234567890").unwrap()), "1234567890");
assert_eq!(
trimmed_hex(&hex::decode("492077697368207275737420737570706F72746564206869676865722D6B696E646564207479706573").unwrap()),
"49207769736820727573742073757070…6865722d6b696e646564207479706573 (41 bytes)"
);
trimmed_hex(&hex::decode("492077697368207275737420737570706F72746564206869676865722D6B696E646564207479706573").unwrap()),
"49207769736820727573742073757070…6865722d6b696e646564207479706573 (41 bytes)"
);
}
}
8 changes: 4 additions & 4 deletions crates/evm/evm/src/executors/fuzz/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use crate::executors::{Executor, RawCallResult};
use alloy_dyn_abi::JsonAbiExt;
use alloy_json_abi::{Function, JsonAbi};
use alloy_json_abi::Function;
use alloy_primitives::{Address, Bytes, U256};
use eyre::Result;
use foundry_config::FuzzConfig;
use foundry_evm_core::{
constants::MAGIC_ASSUME,
decode::{self, decode_console_logs},
decode::{decode_console_logs, RevertDecoder},
};
use foundry_evm_coverage::HitMaps;
use foundry_evm_fuzz::{
Expand Down Expand Up @@ -60,7 +60,7 @@ impl FuzzedExecutor {
func: &Function,
address: Address,
should_fail: bool,
errors: Option<&JsonAbi>,
rd: &RevertDecoder,
) -> FuzzTestResult {
// Stores the first Fuzzcase
let first_case: RefCell<Option<FuzzCase>> = RefCell::default();
Expand Down Expand Up @@ -130,7 +130,7 @@ impl FuzzedExecutor {
let call_res = _counterexample.1.result.clone();
*counterexample.borrow_mut() = _counterexample;
// HACK: we have to use an empty string here to denote `None`
let reason = decode::maybe_decode_revert(&call_res, errors, Some(status));
let reason = rd.maybe_decode(&call_res, Some(status));
Err(TestCaseError::fail(reason.unwrap_or_default()))
}
}
Expand Down
Loading
Loading