Skip to content

Commit 8030cbc

Browse files
authored
feat(cheatcodes): decode and show mismatched params on expectEmit (#11098)
* feat(`cheatcodes`): show mismatched params in events * more param related failure tests * fmt * clippy * feat: decode events using SignaturesIdentifier * correctly identify indexed params * don't decode anon events * fix * clippy * fix test * fix test: build to cache selectors * name mismatched logs * nits * check has_indexed_info * addr checksum * reinstate early data check * fix * nit
1 parent cd21898 commit 8030cbc

File tree

5 files changed

+506
-17
lines changed

5 files changed

+506
-17
lines changed

crates/cheatcodes/src/inspector.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ use foundry_evm_core::{
4141
constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS, MAGIC_ASSUME},
4242
evm::{FoundryEvm, new_evm_with_existing_context},
4343
};
44-
use foundry_evm_traces::{TracingInspector, TracingInspectorConfig};
44+
use foundry_evm_traces::{
45+
TracingInspector, TracingInspectorConfig, identifier::SignaturesIdentifier,
46+
};
4547
use foundry_wallets::multi_wallet::MultiWallet;
4648
use itertools::Itertools;
4749
use proptest::test_runner::{RngAlgorithm, TestRng, TestRunner};
@@ -493,6 +495,8 @@ pub struct Cheatcodes {
493495
pub deprecated: HashMap<&'static str, Option<&'static str>>,
494496
/// Unlocked wallets used in scripts and testing of scripts.
495497
pub wallets: Option<Wallets>,
498+
/// Signatures identifier for decoding events and functions
499+
pub signatures_identifier: Option<SignaturesIdentifier>,
496500
}
497501

498502
// This is not derived because calling this in `fn new` with `..Default::default()` creates a second
@@ -547,6 +551,7 @@ impl Cheatcodes {
547551
arbitrary_storage: Default::default(),
548552
deprecated: Default::default(),
549553
wallets: Default::default(),
554+
signatures_identifier: SignaturesIdentifier::new(true).ok(),
550555
}
551556
}
552557

@@ -1381,10 +1386,14 @@ impl Inspector<EthEvmContext<&mut dyn DatabaseExt>> for Cheatcodes {
13811386
.collect::<Vec<_>>();
13821387

13831388
// Revert if not all emits expected were matched.
1384-
if self.expected_emits.iter().any(|(expected, _)| !expected.found && expected.count > 0)
1389+
if let Some((expected, _)) = self
1390+
.expected_emits
1391+
.iter()
1392+
.find(|(expected, _)| !expected.found && expected.count > 0)
13851393
{
13861394
outcome.result.result = InstructionResult::Revert;
1387-
outcome.result.output = "log != expected log".abi_encode().into();
1395+
let error_msg = expected.mismatch_error.as_deref().unwrap_or("log != expected log");
1396+
outcome.result.output = error_msg.abi_encode().into();
13881397
return;
13891398
}
13901399

crates/cheatcodes/src/test/expect.rs

Lines changed: 256 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ use std::{
44
};
55

66
use crate::{Cheatcode, Cheatcodes, CheatsCtxt, Error, Result, Vm::*};
7+
use alloy_dyn_abi::{DynSolValue, EventExt};
8+
use alloy_json_abi::Event;
79
use alloy_primitives::{
8-
Address, Bytes, LogData as RawLog, U256,
10+
Address, Bytes, LogData as RawLog, U256, hex,
911
map::{AddressHashMap, HashMap, hash_map::Entry},
1012
};
13+
use foundry_common::{abi::get_indexed_event, fmt::format_token};
14+
use foundry_evm_traces::DecodedCallLog;
1115
use revm::{
1216
context::JournalTr,
1317
interpreter::{
@@ -110,6 +114,8 @@ pub struct ExpectedEmit {
110114
pub found: bool,
111115
/// Number of times the log is expected to be emitted
112116
pub count: u64,
117+
/// Stores mismatch details if a log didn't match
118+
pub mismatch_error: Option<String>,
113119
}
114120

115121
#[derive(Clone, Debug)]
@@ -762,8 +768,16 @@ fn expect_emit(
762768
anonymous: bool,
763769
count: u64,
764770
) -> Result {
765-
let expected_emit =
766-
ExpectedEmit { depth, checks, address, found: false, log: None, anonymous, count };
771+
let expected_emit = ExpectedEmit {
772+
depth,
773+
checks,
774+
address,
775+
found: false,
776+
log: None,
777+
anonymous,
778+
count,
779+
mismatch_error: None,
780+
};
767781
if let Some(found_emit_pos) = state.expected_emits.iter().position(|(emit, _)| emit.found) {
768782
// The order of emits already found (back of queue) should not be modified, hence push any
769783
// new emit before first found emit.
@@ -857,11 +871,41 @@ pub(crate) fn handle_expect_emit(
857871

858872
event_to_fill_or_check.found = || -> bool {
859873
if !checks_topics_and_data(event_to_fill_or_check.checks, expected, log) {
874+
// Store detailed mismatch information
875+
876+
// Try to decode the events if we have a signature identifier
877+
let (expected_decoded, actual_decoded) = if let Some(signatures_identifier) =
878+
&state.signatures_identifier
879+
&& !event_to_fill_or_check.anonymous
880+
{
881+
(
882+
decode_event(signatures_identifier, expected),
883+
decode_event(signatures_identifier, log),
884+
)
885+
} else {
886+
(None, None)
887+
};
888+
event_to_fill_or_check.mismatch_error = Some(get_emit_mismatch_message(
889+
event_to_fill_or_check.checks,
890+
expected,
891+
log,
892+
event_to_fill_or_check.anonymous,
893+
expected_decoded.as_ref(),
894+
actual_decoded.as_ref(),
895+
));
860896
return false;
861897
}
862898

863899
// Maybe match source address.
864-
if event_to_fill_or_check.address.is_some_and(|addr| addr != log.address) {
900+
if event_to_fill_or_check
901+
.address
902+
.is_some_and(|addr| addr.to_checksum(None) != log.address.to_checksum(None))
903+
{
904+
event_to_fill_or_check.mismatch_error = Some(format!(
905+
"log emitter mismatch: expected={:#x}, got={:#x}",
906+
event_to_fill_or_check.address.unwrap(),
907+
log.address
908+
));
865909
return false;
866910
}
867911

@@ -1019,6 +1063,214 @@ fn checks_topics_and_data(checks: [bool; 5], expected: &RawLog, log: &RawLog) ->
10191063
true
10201064
}
10211065

1066+
fn decode_event(
1067+
identifier: &foundry_evm_traces::identifier::SignaturesIdentifier,
1068+
log: &RawLog,
1069+
) -> Option<DecodedCallLog> {
1070+
let topics = log.topics();
1071+
if topics.is_empty() {
1072+
return None;
1073+
}
1074+
let t0 = topics[0]; // event sig
1075+
// Try to identify the event
1076+
let event = foundry_common::block_on(identifier.identify_event(t0))?;
1077+
1078+
// Check if event already has indexed information from signatures
1079+
let has_indexed_info = event.inputs.iter().any(|p| p.indexed);
1080+
// Only use get_indexed_event if the event doesn't have indexing info
1081+
let indexed_event = if has_indexed_info { event } else { get_indexed_event(event, log) };
1082+
1083+
// Try to decode the event
1084+
if let Ok(decoded) = indexed_event.decode_log(log) {
1085+
let params = reconstruct_params(&indexed_event, &decoded);
1086+
1087+
let decoded_params = params
1088+
.into_iter()
1089+
.zip(indexed_event.inputs.iter())
1090+
.map(|(param, input)| (input.name.clone(), format_token(&param)))
1091+
.collect();
1092+
1093+
return Some(DecodedCallLog {
1094+
name: Some(indexed_event.name),
1095+
params: Some(decoded_params),
1096+
});
1097+
}
1098+
1099+
None
1100+
}
1101+
1102+
/// Restore the order of the params of a decoded event
1103+
fn reconstruct_params(event: &Event, decoded: &alloy_dyn_abi::DecodedEvent) -> Vec<DynSolValue> {
1104+
let mut indexed = 0;
1105+
let mut unindexed = 0;
1106+
let mut inputs = vec![];
1107+
for input in &event.inputs {
1108+
if input.indexed && indexed < decoded.indexed.len() {
1109+
inputs.push(decoded.indexed[indexed].clone());
1110+
indexed += 1;
1111+
} else if unindexed < decoded.body.len() {
1112+
inputs.push(decoded.body[unindexed].clone());
1113+
unindexed += 1;
1114+
}
1115+
}
1116+
inputs
1117+
}
1118+
1119+
/// Gets a detailed mismatch message for emit assertions
1120+
pub(crate) fn get_emit_mismatch_message(
1121+
checks: [bool; 5],
1122+
expected: &RawLog,
1123+
actual: &RawLog,
1124+
is_anonymous: bool,
1125+
expected_decoded: Option<&DecodedCallLog>,
1126+
actual_decoded: Option<&DecodedCallLog>,
1127+
) -> String {
1128+
// Early return for completely different events or incompatible structures
1129+
1130+
// 1. Different number of topics
1131+
if actual.topics().len() != expected.topics().len() {
1132+
return name_mismatched_logs(expected_decoded, actual_decoded);
1133+
}
1134+
1135+
// 2. Different event signatures (for non-anonymous events)
1136+
if !is_anonymous
1137+
&& checks[0]
1138+
&& (!expected.topics().is_empty() && !actual.topics().is_empty())
1139+
&& expected.topics()[0] != actual.topics()[0]
1140+
{
1141+
return name_mismatched_logs(expected_decoded, actual_decoded);
1142+
}
1143+
1144+
let expected_data = expected.data.as_ref();
1145+
let actual_data = actual.data.as_ref();
1146+
1147+
// 3. Check data
1148+
if checks[4] && expected_data != actual_data {
1149+
// Different lengths or not ABI-encoded
1150+
if expected_data.len() != actual_data.len()
1151+
|| !expected_data.len().is_multiple_of(32)
1152+
|| expected_data.is_empty()
1153+
{
1154+
return name_mismatched_logs(expected_decoded, actual_decoded);
1155+
}
1156+
}
1157+
1158+
// expected and actual events are the same, so check individual parameters
1159+
let mut mismatches = Vec::new();
1160+
1161+
// Check topics (indexed parameters)
1162+
for (i, (expected_topic, actual_topic)) in
1163+
expected.topics().iter().zip(actual.topics().iter()).enumerate()
1164+
{
1165+
// Skip topic[0] for non-anonymous events (already checked above)
1166+
if i == 0 && !is_anonymous {
1167+
continue;
1168+
}
1169+
1170+
// Only check if the corresponding check flag is set
1171+
if i < checks.len() && checks[i] && expected_topic != actual_topic {
1172+
let param_idx = if is_anonymous {
1173+
i // For anonymous events, topic[0] is param 0
1174+
} else {
1175+
i - 1 // For regular events, topic[0] is event signature, so topic[1] is param 0
1176+
};
1177+
mismatches
1178+
.push(format!("param {param_idx}: expected={expected_topic}, got={actual_topic}"));
1179+
}
1180+
}
1181+
1182+
// Check data (non-indexed parameters)
1183+
if checks[4] && expected_data != actual_data {
1184+
let num_indexed_params = if is_anonymous {
1185+
expected.topics().len()
1186+
} else {
1187+
expected.topics().len().saturating_sub(1)
1188+
};
1189+
1190+
for (i, (expected_chunk, actual_chunk)) in
1191+
expected_data.chunks(32).zip(actual_data.chunks(32)).enumerate()
1192+
{
1193+
if expected_chunk != actual_chunk {
1194+
let param_idx = num_indexed_params + i;
1195+
mismatches.push(format!(
1196+
"param {}: expected={}, got={}",
1197+
param_idx,
1198+
hex::encode_prefixed(expected_chunk),
1199+
hex::encode_prefixed(actual_chunk)
1200+
));
1201+
}
1202+
}
1203+
}
1204+
1205+
if mismatches.is_empty() {
1206+
name_mismatched_logs(expected_decoded, actual_decoded)
1207+
} else {
1208+
// Build the error message with event names if available
1209+
let event_prefix = match (expected_decoded, actual_decoded) {
1210+
(Some(expected_dec), Some(actual_dec)) if expected_dec.name == actual_dec.name => {
1211+
format!(
1212+
"{} param mismatch",
1213+
expected_dec.name.as_ref().unwrap_or(&"log".to_string())
1214+
)
1215+
}
1216+
_ => {
1217+
if is_anonymous {
1218+
"anonymous log mismatch".to_string()
1219+
} else {
1220+
"log mismatch".to_string()
1221+
}
1222+
}
1223+
};
1224+
1225+
// Add parameter details if available from decoded events
1226+
let detailed_mismatches = if let (Some(expected_dec), Some(actual_dec)) =
1227+
(expected_decoded, actual_decoded)
1228+
&& let (Some(expected_params), Some(actual_params)) =
1229+
(&expected_dec.params, &actual_dec.params)
1230+
{
1231+
mismatches
1232+
.into_iter()
1233+
.map(|basic_mismatch| {
1234+
// Try to find the parameter name and decoded value
1235+
if let Some(param_idx) = basic_mismatch
1236+
.split(' ')
1237+
.nth(1)
1238+
.and_then(|s| s.trim_end_matches(':').parse::<usize>().ok())
1239+
&& param_idx < expected_params.len()
1240+
&& param_idx < actual_params.len()
1241+
{
1242+
let (expected_name, expected_value) = &expected_params[param_idx];
1243+
let (_actual_name, actual_value) = &actual_params[param_idx];
1244+
let param_name = if !expected_name.is_empty() {
1245+
expected_name
1246+
} else {
1247+
&format!("param{param_idx}")
1248+
};
1249+
return format!(
1250+
"{param_name}: expected={expected_value}, got={actual_value}",
1251+
);
1252+
}
1253+
basic_mismatch
1254+
})
1255+
.collect::<Vec<_>>()
1256+
} else {
1257+
mismatches
1258+
};
1259+
1260+
format!("{} at {}", event_prefix, detailed_mismatches.join(", "))
1261+
}
1262+
}
1263+
1264+
/// Formats the generic mismatch message: "log != expected log" to include event names if available
1265+
fn name_mismatched_logs(
1266+
expected_decoded: Option<&DecodedCallLog>,
1267+
actual_decoded: Option<&DecodedCallLog>,
1268+
) -> String {
1269+
let expected_name = expected_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log");
1270+
let actual_name = actual_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log");
1271+
format!("{actual_name} != expected {expected_name}")
1272+
}
1273+
10221274
fn expect_safe_memory(state: &mut Cheatcodes, start: u64, end: u64, depth: u64) -> Result {
10231275
ensure!(start < end, "memory range start ({start}) is greater than end ({end})");
10241276
#[expect(clippy::single_range_in_vec_init)] // Wanted behaviour

0 commit comments

Comments
 (0)