diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 82a7de2aa509..d3a47288ff61 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -4971,6 +4971,86 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "expectEmit_4", + "description": "Expect a given number of logs with the provided topics.", + "declaration": "function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData, uint64 count) external;", + "visibility": "external", + "mutability": "", + "signature": "expectEmit(bool,bool,bool,bool,uint64)", + "selector": "0x5e1d1c33", + "selectorBytes": [ + 94, + 29, + 28, + 51 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "expectEmit_5", + "description": "Expect a given number of logs from a specific emitter with the provided topics.", + "declaration": "function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData, address emitter, uint64 count) external;", + "visibility": "external", + "mutability": "", + "signature": "expectEmit(bool,bool,bool,bool,address,uint64)", + "selector": "0xc339d02c", + "selectorBytes": [ + 195, + 57, + 208, + 44 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "expectEmit_6", + "description": "Expect a given number of logs with all topic and data checks enabled.", + "declaration": "function expectEmit(uint64 count) external;", + "visibility": "external", + "mutability": "", + "signature": "expectEmit(uint64)", + "selector": "0x4c74a335", + "selectorBytes": [ + 76, + 116, + 163, + 53 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "expectEmit_7", + "description": "Expect a given number of logs from a specific emitter with all topic and data checks enabled.", + "declaration": "function expectEmit(address emitter, uint64 count) external;", + "visibility": "external", + "mutability": "", + "signature": "expectEmit(address,uint64)", + "selector": "0xb43aece3", + "selectorBytes": [ + 180, + 58, + 236, + 227 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "expectPartialRevert_0", diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index 4bc8c9b0344e..47e0b625b4de 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -982,6 +982,23 @@ interface Vm { #[cheatcode(group = Testing, safety = Unsafe)] function expectEmit(address emitter) external; + /// Expect a given number of logs with the provided topics. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData, uint64 count) external; + + /// Expect a given number of logs from a specific emitter with the provided topics. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData, address emitter, uint64 count) + external; + + /// Expect a given number of logs with all topic and data checks enabled. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectEmit(uint64 count) external; + + /// Expect a given number of logs from a specific emitter with all topic and data checks enabled. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectEmit(address emitter, uint64 count) external; + /// Prepare an expected anonymous log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData.). /// Call this function, then emit an anonymous event, then call a function. Internally after the call, we check if /// logs were emitted in the expected order with the expected topics and data (as specified by the booleans). diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 329b89d0ebc2..a0695d0d254f 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -12,7 +12,7 @@ use crate::{ test::{ assume::AssumeNoRevert, expect::{ - self, ExpectedCallData, ExpectedCallTracker, ExpectedCallType, ExpectedEmit, + self, ExpectedCallData, ExpectedCallTracker, ExpectedCallType, ExpectedEmitTracker, ExpectedRevert, ExpectedRevertKind, }, }, @@ -428,7 +428,7 @@ pub struct Cheatcodes { /// Expected calls pub expected_calls: ExpectedCallTracker, /// Expected emits - pub expected_emits: VecDeque, + pub expected_emits: ExpectedEmitTracker, /// Map of context depths to memory offset ranges that may be written to within the call depth. pub allowed_mem_writes: HashMap>>, @@ -1442,21 +1442,63 @@ impl Inspector<&mut dyn DatabaseExt> for Cheatcodes { let should_check_emits = self .expected_emits .iter() - .any(|expected| expected.depth == ecx.journaled_state.depth()) && + .any(|(expected, _)| expected.depth == ecx.journaled_state.depth()) && // Ignore staticcalls !call.is_static; if should_check_emits { + let expected_counts = self + .expected_emits + .iter() + .filter_map(|(expected, count_map)| { + let count = match expected.address { + Some(emitter) => match count_map.get(&emitter) { + Some(log_count) => expected + .log + .as_ref() + .map(|l| log_count.count(l)) + .unwrap_or_else(|| log_count.count_unchecked()), + None => 0, + }, + None => match &expected.log { + Some(log) => count_map.values().map(|logs| logs.count(log)).sum(), + None => count_map.values().map(|logs| logs.count_unchecked()).sum(), + }, + }; + + if count != expected.count { + Some((expected, count)) + } else { + None + } + }) + .collect::>(); + // Not all emits were matched. - if self.expected_emits.iter().any(|expected| !expected.found) { + if self.expected_emits.iter().any(|(expected, _)| !expected.found) { outcome.result.result = InstructionResult::Revert; outcome.result.output = "log != expected log".abi_encode().into(); return outcome; - } else { - // All emits were found, we're good. - // Clear the queue, as we expect the user to declare more events for the next call - // if they wanna match further events. - self.expected_emits.clear() } + + if !expected_counts.is_empty() { + let msg = if outcome.result.is_ok() { + let (expected, count) = expected_counts.first().unwrap(); + format!("log emitted {count} times, expected {}", expected.count) + } else { + "expected an emit, but the call reverted instead. \ + ensure you're testing the happy path when using `expectEmit`" + .to_string() + }; + + outcome.result.result = InstructionResult::Revert; + outcome.result.output = Error::encode(msg); + return outcome; + } + + // All emits were found, we're good. + // Clear the queue, as we expect the user to declare more events for the next call + // if they wanna match further events. + self.expected_emits.clear() } // this will ensure we don't have false positives when trying to diagnose reverts in fork @@ -1544,10 +1586,9 @@ impl Inspector<&mut dyn DatabaseExt> for Cheatcodes { } } } - // Check if we have any leftover expected emits // First, if any emits were found at the root call, then we its ok and we remove them. - self.expected_emits.retain(|expected| !expected.found); + self.expected_emits.retain(|(expected, _)| expected.count > 0 && !expected.found); // If not empty, we got mismatched emits if !self.expected_emits.is_empty() { let msg = if outcome.result.is_ok() { diff --git a/crates/cheatcodes/src/test/expect.rs b/crates/cheatcodes/src/test/expect.rs index a3ddef8c16c9..e45298923cd6 100644 --- a/crates/cheatcodes/src/test/expect.rs +++ b/crates/cheatcodes/src/test/expect.rs @@ -1,7 +1,9 @@ +use std::collections::VecDeque; + use crate::{Cheatcode, Cheatcodes, CheatsCtxt, Error, Result, Vm::*}; use alloy_primitives::{ address, hex, - map::{hash_map::Entry, HashMap}, + map::{hash_map::Entry, AddressHashMap, HashMap}, Address, Bytes, LogData as RawLog, U256, }; use alloy_sol_types::{SolError, SolValue}; @@ -113,6 +115,8 @@ pub struct ExpectedEmit { pub anonymous: bool, /// Whether the log was actually found in the subcalls pub found: bool, + /// Number of times the log is expected to be emitted + pub count: u64, } impl Cheatcode for expectCall_0Call { @@ -225,6 +229,7 @@ impl Cheatcode for expectEmit_0Call { [true, checkTopic1, checkTopic2, checkTopic3, checkData], None, false, + 1, ) } } @@ -238,6 +243,7 @@ impl Cheatcode for expectEmit_1Call { [true, checkTopic1, checkTopic2, checkTopic3, checkData], Some(emitter), false, + 1, ) } } @@ -245,14 +251,63 @@ impl Cheatcode for expectEmit_1Call { impl Cheatcode for expectEmit_2Call { fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { let Self {} = self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, false) + expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, false, 1) } } impl Cheatcode for expectEmit_3Call { fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { let Self { emitter } = *self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], Some(emitter), false) + expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], Some(emitter), false, 1) + } +} + +impl Cheatcode for expectEmit_4Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { checkTopic1, checkTopic2, checkTopic3, checkData, count } = *self; + expect_emit( + ccx.state, + ccx.ecx.journaled_state.depth(), + [true, checkTopic1, checkTopic2, checkTopic3, checkData], + None, + false, + count, + ) + } +} + +impl Cheatcode for expectEmit_5Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { checkTopic1, checkTopic2, checkTopic3, checkData, emitter, count } = *self; + expect_emit( + ccx.state, + ccx.ecx.journaled_state.depth(), + [true, checkTopic1, checkTopic2, checkTopic3, checkData], + Some(emitter), + false, + count, + ) + } +} + +impl Cheatcode for expectEmit_6Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { count } = *self; + expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, false, count) + } +} + +impl Cheatcode for expectEmit_7Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { emitter, count } = *self; + expect_emit( + ccx.state, + ccx.ecx.journaled_state.depth(), + [true; 5], + Some(emitter), + false, + count, + ) } } @@ -265,6 +320,7 @@ impl Cheatcode for expectEmitAnonymous_0Call { [checkTopic0, checkTopic1, checkTopic2, checkTopic3, checkData], None, true, + 1, ) } } @@ -278,6 +334,7 @@ impl Cheatcode for expectEmitAnonymous_1Call { [checkTopic0, checkTopic1, checkTopic2, checkTopic3, checkData], Some(emitter), true, + 1, ) } } @@ -285,14 +342,14 @@ impl Cheatcode for expectEmitAnonymous_1Call { impl Cheatcode for expectEmitAnonymous_2Call { fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { let Self {} = self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, true) + expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, true, 1) } } impl Cheatcode for expectEmitAnonymous_3Call { fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { let Self { emitter } = *self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], Some(emitter), true) + expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], Some(emitter), true, 1) } } @@ -638,15 +695,17 @@ fn expect_emit( checks: [bool; 5], address: Option
, anonymous: bool, + count: u64, ) -> Result { - let expected_emit = ExpectedEmit { depth, checks, address, found: false, log: None, anonymous }; - if let Some(found_emit_pos) = state.expected_emits.iter().position(|emit| emit.found) { + let expected_emit = + ExpectedEmit { depth, checks, address, found: false, log: None, anonymous, count }; + if let Some(found_emit_pos) = state.expected_emits.iter().position(|(emit, _)| emit.found) { // The order of emits already found (back of queue) should not be modified, hence push any // new emit before first found emit. - state.expected_emits.insert(found_emit_pos, expected_emit); + state.expected_emits.insert(found_emit_pos, (expected_emit, Default::default())); } else { // If no expected emits then push new one at the back of queue. - state.expected_emits.push_back(expected_emit); + state.expected_emits.push_back((expected_emit, Default::default())); } Ok(Default::default()) @@ -667,18 +726,18 @@ pub(crate) fn handle_expect_emit( // First, we can return early if all events have been matched. // This allows a contract to arbitrarily emit more events than expected (additive behavior), // as long as all the previous events were matched in the order they were expected to be. - if state.expected_emits.iter().all(|expected| expected.found) { + if state.expected_emits.iter().all(|(expected, _)| expected.found) { return } - let should_fill_logs = state.expected_emits.iter().any(|expected| expected.log.is_none()); + let should_fill_logs = state.expected_emits.iter().any(|(expected, _)| expected.log.is_none()); let index_to_fill_or_check = if should_fill_logs { // If there's anything to fill, we start with the last event to match in the queue // (without taking into account events already matched). state .expected_emits .iter() - .position(|emit| emit.found) + .position(|(emit, _)| emit.found) .unwrap_or(state.expected_emits.len()) .saturating_sub(1) } else { @@ -687,7 +746,7 @@ pub(crate) fn handle_expect_emit( 0 }; - let mut event_to_fill_or_check = state + let (mut event_to_fill_or_check, mut count_map) = state .expected_emits .remove(index_to_fill_or_check) .expect("we should have an emit to fill or check"); @@ -698,7 +757,9 @@ pub(crate) fn handle_expect_emit( if event_to_fill_or_check.anonymous || !log.topics().is_empty() { event_to_fill_or_check.log = Some(log.data.clone()); // If we only filled the expected log then we put it back at the same position. - state.expected_emits.insert(index_to_fill_or_check, event_to_fill_or_check); + state + .expected_emits + .insert(index_to_fill_or_check, (event_to_fill_or_check, count_map)); } else { interpreter.instruction_result = InstructionResult::Revert; interpreter.next_action = InterpreterAction::Return { @@ -712,41 +773,120 @@ pub(crate) fn handle_expect_emit( return }; - event_to_fill_or_check.found = || -> bool { - // Topic count must match. - if expected.topics().len() != log.topics().len() { - return false + // Increment/set `count` for `log.address` and `log.data` + match count_map.entry(log.address) { + Entry::Occupied(mut entry) => { + // Checks and inserts the log into the map. + // If the log doesn't pass the checks, it is ignored and `count` is not incremented. + let log_count_map = entry.get_mut(); + log_count_map.insert(&log.data); } - // Match topics according to the checks. - if !log - .topics() - .iter() - .enumerate() - .filter(|(i, _)| event_to_fill_or_check.checks[*i]) - .all(|(i, topic)| topic == &expected.topics()[i]) - { + Entry::Vacant(entry) => { + let mut log_count_map = LogCountMap::new(&event_to_fill_or_check); + + if log_count_map.satisfies_checks(&log.data) { + log_count_map.insert(&log.data); + + // Entry is only inserted if it satisfies the checks. + entry.insert(log_count_map); + } + } + } + + event_to_fill_or_check.found = || -> bool { + if !checks_topics_and_data(event_to_fill_or_check.checks, expected, log) { return false } + // Maybe match source address. if event_to_fill_or_check.address.is_some_and(|addr| addr != log.address) { return false; } - // Maybe match data. - if event_to_fill_or_check.checks[4] && expected.data.as_ref() != log.data.data.as_ref() { - return false - } - true + let expected_count = event_to_fill_or_check.count; + + match event_to_fill_or_check.address { + Some(emitter) => count_map + .get(&emitter) + .is_some_and(|log_map| log_map.count(&log.data) >= expected_count), + None => count_map + .values() + .find(|log_map| log_map.satisfies_checks(&log.data)) + .is_some_and(|map| map.count(&log.data) >= expected_count), + } }(); // If we found the event, we can push it to the back of the queue // and begin expecting the next event. if event_to_fill_or_check.found { - state.expected_emits.push_back(event_to_fill_or_check); + state.expected_emits.push_back((event_to_fill_or_check, count_map)); } else { // We did not match this event, so we need to keep waiting for the right one to // appear. - state.expected_emits.push_front(event_to_fill_or_check); + state.expected_emits.push_front((event_to_fill_or_check, count_map)); + } +} + +/// Handles expected emits specified by the `expectEmit` cheatcodes. +/// +/// The second element of the tuple counts the number of times the log has been emitted by a +/// particular address +pub type ExpectedEmitTracker = VecDeque<(ExpectedEmit, AddressHashMap)>; + +#[derive(Clone, Debug, Default)] +pub struct LogCountMap { + checks: [bool; 5], + expected_log: RawLog, + map: HashMap, +} + +impl LogCountMap { + /// Instantiates `LogCountMap`. + fn new(expected_emit: &ExpectedEmit) -> Self { + Self { + checks: expected_emit.checks, + expected_log: expected_emit.log.clone().expect("log should be filled here"), + map: Default::default(), + } + } + + /// Inserts a log into the map and increments the count. + /// + /// The log must pass all checks against the expected log for the count to increment. + /// + /// Returns true if the log was inserted and count was incremented. + fn insert(&mut self, log: &RawLog) -> bool { + // If its already in the map, increment the count without checking. + if self.map.contains_key(log) { + self.map.entry(log.clone()).and_modify(|c| *c += 1); + + return true + } + + if !self.satisfies_checks(log) { + return false + } + + self.map.entry(log.clone()).and_modify(|c| *c += 1).or_insert(1); + + true + } + + /// Checks the incoming raw log against the expected logs topics and data. + fn satisfies_checks(&self, log: &RawLog) -> bool { + checks_topics_and_data(self.checks, &self.expected_log, log) + } + + pub fn count(&self, log: &RawLog) -> u64 { + if !self.satisfies_checks(log) { + return 0 + } + + self.count_unchecked() + } + + pub fn count_unchecked(&self) -> u64 { + self.map.values().sum() } } @@ -910,6 +1050,30 @@ pub(crate) fn handle_expect_revert( } } +fn checks_topics_and_data(checks: [bool; 5], expected: &RawLog, log: &RawLog) -> bool { + if log.topics().len() != expected.topics().len() { + return false + } + + // Check topics. + if !log + .topics() + .iter() + .enumerate() + .filter(|(i, _)| checks[*i]) + .all(|(i, topic)| topic == &expected.topics()[i]) + { + return false + } + + // Check data + if checks[4] && expected.data.as_ref() != log.data.as_ref() { + return false + } + + true +} + fn expect_safe_memory(state: &mut Cheatcodes, start: u64, end: u64, depth: u64) -> Result { ensure!(start < end, "memory range start ({start}) is greater than end ({end})"); #[allow(clippy::single_range_in_vec_init)] // Wanted behaviour diff --git a/testdata/cheats/Vm.sol b/testdata/cheats/Vm.sol index bdbb68e372e8..260b5bb38560 100644 --- a/testdata/cheats/Vm.sol +++ b/testdata/cheats/Vm.sol @@ -242,6 +242,10 @@ interface Vm { function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData, address emitter) external; function expectEmit() external; function expectEmit(address emitter) external; + function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData, uint64 count) external; + function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData, address emitter, uint64 count) external; + function expectEmit(uint64 count) external; + function expectEmit(address emitter, uint64 count) external; function expectPartialRevert(bytes4 revertData) external; function expectPartialRevert(bytes4 revertData, address reverter) external; function expectRevert() external; diff --git a/testdata/default/cheats/ExpectEmit.t.sol b/testdata/default/cheats/ExpectEmit.t.sol index b8fe5e4587c7..2503faf4bcf7 100644 --- a/testdata/default/cheats/ExpectEmit.t.sol +++ b/testdata/default/cheats/ExpectEmit.t.sol @@ -28,6 +28,12 @@ contract Emitter { emit Something(topic1, topic2, topic3, data); } + function emitNEvents(uint256 topic1, uint256 topic2, uint256 topic3, uint256 data, uint256 n) public { + for (uint256 i = 0; i < n; i++) { + emit Something(topic1, topic2, topic3, data); + } + } + function emitMultiple( uint256[2] memory topic1, uint256[2] memory topic2, @@ -597,3 +603,82 @@ contract ExpectEmitTest is DSTest { // emitter.emitEvent(1, 2, 3, 4); // } } + +contract ExpectEmitCountTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + Emitter emitter; + + event Something(uint256 indexed topic1, uint256 indexed topic2, uint256 indexed topic3, uint256 data); + + function setUp() public { + emitter = new Emitter(); + } + + function testCountNoEmit() public { + vm.expectEmit(0); + emit Something(1, 2, 3, 4); + emitter.doesNothing(); + } + + function testFailNoEmit() public { + vm.expectEmit(0); + emit Something(1, 2, 3, 4); + emitter.emitEvent(1, 2, 3, 4); + } + + function testCountNEmits() public { + uint64 count = 2; + vm.expectEmit(count); + emit Something(1, 2, 3, 4); + emitter.emitNEvents(1, 2, 3, 4, count); + } + + function testFailCountLessEmits() public { + uint64 count = 2; + vm.expectEmit(count); + emit Something(1, 2, 3, 4); + emitter.emitNEvents(1, 2, 3, 4, count - 1); + } + + function testCountMoreEmits() public { + uint64 count = 2; + vm.expectEmit(count); + emit Something(1, 2, 3, 4); + emitter.emitNEvents(1, 2, 3, 4, count + 1); + } + + /// Test zero emits from a specific address (emitter). + + function testCountNoEmitFromAddress() public { + vm.expectEmit(address(emitter), 0); + emit Something(1, 2, 3, 4); + emitter.doesNothing(); + } + + function testFailNoEmitFromAddress() public { + vm.expectEmit(address(emitter), 0); + emit Something(1, 2, 3, 4); + emitter.emitEvent(1, 2, 3, 4); + } + + function testCountEmitsFromAddress() public { + uint64 count = 2; + vm.expectEmit(address(emitter), count); + emit Something(1, 2, 3, 4); + emitter.emitNEvents(1, 2, 3, 4, count); + } + + function testFailCountEmitsFromAddress() public { + uint64 count = 3; + vm.expectEmit(address(emitter), count); + emit Something(1, 2, 3, 4); + emitter.emitNEvents(1, 2, 3, 4, count - 1); + } + + function testFailEmitSomethingElse() public { + uint64 count = 2; + vm.expectEmit(count); + emit Something(1, 2, 3, 4); + emitter.emitSomethingElse(23214); + } +}