From d5de05ef2ce6a547406efbc322cddc671836d332 Mon Sep 17 00:00:00 2001 From: Karrq Date: Fri, 5 Jan 2024 23:03:15 +0800 Subject: [PATCH] feat(era-cheatcodes): `expectRevert` cheatcode (#200) Signed-off-by: Danil Co-authored-by: Nisheeth Barthwal Co-authored-by: Danil --- crates/era-cheatcodes/src/cheatcodes.rs | 340 +++++++++++++++++- .../tests/src/cheatcodes/ExpectRevert.t.sol | 246 +++++++++++++ 2 files changed, 567 insertions(+), 19 deletions(-) create mode 100644 crates/era-cheatcodes/tests/src/cheatcodes/ExpectRevert.t.sol diff --git a/crates/era-cheatcodes/src/cheatcodes.rs b/crates/era-cheatcodes/src/cheatcodes.rs index aa4db6bbb..c4212057d 100644 --- a/crates/era-cheatcodes/src/cheatcodes.rs +++ b/crates/era-cheatcodes/src/cheatcodes.rs @@ -17,15 +17,17 @@ use foundry_evm_core::{ }; use itertools::Itertools; use multivm::{ - interface::{dyn_tracers::vm_1_4_0::DynTracer, tracer::TracerExecutionStatus}, + interface::{dyn_tracers::vm_1_4_0::DynTracer, tracer::TracerExecutionStatus, VmRevertReason}, vm_latest::{ BootloaderState, HistoryMode, L1BatchEnv, SimpleMemory, SystemEnv, VmTracer, ZkSyncVmState, }, zk_evm_1_4_0::{ tracing::{AfterExecutionData, VmLocalStateData}, - vm_state::PrimitiveValue, + vm_state::{PrimitiveValue, VmLocalState}, zkevm_opcode_defs::{ - FatPointer, Opcode, CALL_IMPLICIT_CALLDATA_FAT_PTR_REGISTER, + self, + decoding::{EncodingModeProduction, VmEncodingMode}, + FatPointer, Opcode, RetOpcode, CALL_IMPLICIT_CALLDATA_FAT_PTR_REGISTER, RET_IMPLICIT_RETURNDATA_PARAMS_REGISTER, }, }, @@ -51,11 +53,12 @@ use zksync_types::{ block::{pack_block_info, unpack_block_info}, get_code_key, get_nonce_key, utils::{decompose_full_nonce, nonces_to_full_nonce, storage_key_for_eth_balance}, - LogQuery, StorageKey, Timestamp, + LogQuery, StorageKey, Timestamp, ACCOUNT_CODE_STORAGE_ADDRESS, }; use zksync_utils::{h256_to_u256, u256_to_h256}; type EraDb = StorageView>>; +type PcOrImm = >::PcOrImm; // address(uint160(uint256(keccak256('hevm cheat code')))) const CHEATCODE_ADDRESS: H160 = H160([ @@ -111,6 +114,7 @@ enum FoundryTestState { #[derive(Debug, Default, Clone)] pub struct CheatcodeTracer { one_time_actions: Vec, + next_return_action: Option, permanent_actions: FinishCyclePermanentActions, return_data: Option>, return_ptr: Option, @@ -164,6 +168,8 @@ enum ExpectedEmitState { enum FinishCycleOneTimeActions { StorageWrite { key: StorageKey, read_value: H256, write_value: H256 }, StoreFactoryDep { hash: U256, bytecode: Vec }, + ForceRevert { error: Vec, exception_handler: PcOrImm }, + ForceReturn { data: Vec, continue_pc: PcOrImm }, CreateSelectFork { url_or_alias: String, block_number: Option }, CreateFork { url_or_alias: String, block_number: Option }, SelectFork { fork_id: U256 }, @@ -171,6 +177,25 @@ enum FinishCycleOneTimeActions { Snapshot, } +#[derive(Debug, Clone)] +struct NextReturnAction { + /// Target depth where the next statement would be + target_depth: usize, + /// Action to queue when the condition is satisfied + action: ActionOnReturn, + returns_to_skip: usize, +} + +#[derive(Debug, Clone)] +enum ActionOnReturn { + ExpectRevert { + reason: Option>, + depth: usize, + prev_continue_pc: Option, + prev_exception_handler_pc: Option, + }, +} + #[derive(Debug, Default, Clone)] struct FinishCyclePermanentActions { start_prank: Option, @@ -218,6 +243,44 @@ enum ExpectedCallType { impl DynTracer, SimpleMemory> for CheatcodeTracer { + fn before_execution( + &mut self, + state: VmLocalStateData<'_>, + data: multivm::zk_evm_1_4_0::tracing::BeforeExecutionData, + _memory: &SimpleMemory, + _storage: StoragePtr>, + ) { + //store the current exception handler in expect revert + // to be used to force a revert + if let Some(ActionOnReturn::ExpectRevert { + prev_exception_handler_pc, + prev_continue_pc, + .. + }) = self.current_expect_revert() + { + if matches!(data.opcode.variant.opcode, Opcode::Ret(_)) { + // Callstack on the desired depth, it has the correct pc for continue + let last = state.vm_local_state.callstack.inner.last().unwrap(); + // Callstack on the current depth, it has the correct pc for exception handler and + // is_local_frame + let current = &state.vm_local_state.callstack.current; + let is_to_label: bool = data.opcode.variant.flags + [zkevm_opcode_defs::RET_TO_LABEL_BIT_IDX] & + state.vm_local_state.callstack.current.is_local_frame; + tracing::debug!(%is_to_label, ?last, "storing continuations"); + + // The source https://github.com/matter-labs/era-zk_evm/blob/763ef5dfd52fecde36bfdd01d47589b61eabf118/src/opcodes/execution/ret.rs#L242 + if is_to_label { + prev_continue_pc.replace(data.opcode.imm_0); + } else { + prev_continue_pc.replace(last.pc); + } + + prev_exception_handler_pc.replace(current.exception_handler_location); + } + } + } + fn after_execution( &mut self, state: VmLocalStateData<'_>, @@ -282,6 +345,9 @@ impl DynTracer, SimpleMemory> self.reset_test_status(); } + // Checks returns from caontracts for expectRevert cheatcode + self.handle_return(&state, &data, memory); + // Checks contract calls for expectCall cheatcode if let Opcode::FarCall(_call) = data.opcode.variant.opcode { let current = state.vm_local_state.callstack.current; @@ -308,6 +374,8 @@ impl DynTracer, SimpleMemory> } } + let current = state.vm_local_state.callstack.get_current_stack(); + if self.return_data.is_some() { if let Opcode::Ret(_call) = data.opcode.variant.opcode { if self.near_calls == 0 { @@ -328,7 +396,17 @@ impl DynTracer, SimpleMemory> } if let Opcode::FarCall(_call) = data.opcode.variant.opcode { - let current = state.vm_local_state.callstack.current; + if current.code_address == ACCOUNT_CODE_STORAGE_ADDRESS { + if let Some(action) = &mut self.next_return_action { + // if the call is to the account storage contract, we need to skip the next + // return and our code assumes that we are working with return opcode, so we + // have to increase target depth + if action.target_depth + 1 == state.vm_local_state.callstack.depth() { + action.returns_to_skip += 1; + } + } + } + if current.code_address != CHEATCODE_ADDRESS { return } @@ -625,22 +703,50 @@ impl VmTracer, H> for CheatcodeT storage.modified_storage_keys = modified_storage; self.return_data = Some(vec![snapshot_id.to_u256()]); } + FinishCycleOneTimeActions::ForceReturn { data, continue_pc: pc } => { + tracing::debug!("!!!! FORCING RETURN"); + + self.add_trimmed_return_data(data.as_slice()); + let ptr = state.local_state.registers + [RET_IMPLICIT_RETURNDATA_PARAMS_REGISTER as usize]; + let fat_data_pointer = FatPointer::from_u256(ptr.value); + + Self::set_return( + fat_data_pointer, + self.return_data.take().unwrap(), + &mut state.local_state, + &mut state.memory, + ); + + //change current stack pc to label + state.local_state.callstack.get_current_stack_mut().pc = pc; + } + FinishCycleOneTimeActions::ForceRevert { error, exception_handler: pc } => { + tracing::debug!("!!! FORCING REVERT"); + + self.add_trimmed_return_data(error.as_slice()); + let ptr = state.local_state.registers + [RET_IMPLICIT_RETURNDATA_PARAMS_REGISTER as usize]; + let fat_data_pointer = FatPointer::from_u256(ptr.value); + + Self::set_return( + fat_data_pointer, + self.return_data.take().unwrap(), + &mut state.local_state, + &mut state.memory, + ); + + //change current stack pc to exception handler + state.local_state.callstack.get_current_stack_mut().pc = pc; + } } } // Set return data, if any - if let Some(mut fat_pointer) = self.return_ptr.take() { - let timestamp = Timestamp(state.local_state.timestamp); - + if let Some(fat_pointer) = self.return_ptr.take() { let elements = self.return_data.take().unwrap(); - fat_pointer.length = (elements.len() as u32) * 32; - state.local_state.registers[RET_IMPLICIT_RETURNDATA_PARAMS_REGISTER as usize] = - PrimitiveValue { value: fat_pointer.to_u256(), is_pointer: true }; - state.memory.populate_page( - fat_pointer.memory_page as usize, - elements.into_iter().enumerate().collect_vec(), - timestamp, - ); + + Self::set_return(fat_pointer, elements, &mut state.local_state, &mut state.memory); } // Sets the sender address for startPrank cheatcode @@ -684,7 +790,7 @@ impl CheatcodeTracer { self.test_status = FoundryTestState::Running { call_depth: state.vm_local_state.callstack.depth(), }; - tracing::info!("Test started"); + tracing::info!("Test started depth {}", state.vm_local_state.callstack.depth()); } } Opcode::Ret(_) => { @@ -693,7 +799,8 @@ impl CheatcodeTracer { // popped (so reduced by 1) and must be accounted for. if call_depth == state.vm_local_state.callstack.depth() + 1 { self.test_status = FoundryTestState::Finished; - tracing::info!("Test finished"); + tracing::info!("Test finished {}", state.vm_local_state.callstack.depth()); + // panic!("Test finished") } } } @@ -739,6 +846,21 @@ impl CheatcodeTracer { self.store_factory_dep(hash, code); self.write_storage(code_key, u256_to_h256(hash), &mut storage.borrow_mut()); } + expectRevert_0(expectRevert_0Call {}) => { + let depth = state.vm_local_state.callstack.depth(); + tracing::info!(%depth, "👷 Setting up expectRevert for any reason"); + self.add_expect_revert(None, depth) + } + expectRevert_1(expectRevert_1Call { revertData }) => { + let depth = state.vm_local_state.callstack.depth(); + tracing::info!(%depth, reason = ?revertData, "👷 Setting up expectRevert with bytes4 reason"); + self.add_expect_revert(Some(revertData.to_vec()), depth) + } + expectRevert_2(expectRevert_2Call { revertData }) => { + let depth = state.vm_local_state.callstack.depth(); + tracing::info!(%depth, reason = ?revertData, "👷 Setting up expectRevert with reason"); + self.add_expect_revert(Some(revertData.to_vec()), depth) + } expectCall_0(expectCall_0Call { callee, data }) => { tracing::info!("👷 Setting expected call to {callee:?}"); self.expect_call(&callee.to_h160(), &data, None, 1, ExpectedCallType::NonCount); @@ -1357,8 +1479,117 @@ impl CheatcodeTracer { self.return_data = Some(data); } + fn set_return( + mut fat_pointer: FatPointer, + elements: Vec, + state: &mut VmLocalState, + memory: &mut SimpleMemory, + ) { + let timestamp = Timestamp(state.timestamp); + + fat_pointer.length = (elements.len() as u32) * 32; + state.registers[RET_IMPLICIT_RETURNDATA_PARAMS_REGISTER as usize] = + PrimitiveValue { value: fat_pointer.to_u256(), is_pointer: true }; + memory.populate_page( + fat_pointer.memory_page as usize, + elements.into_iter().enumerate().collect_vec(), + timestamp, + ); + } + + fn current_expect_revert(&mut self) -> Option<&mut ActionOnReturn> { + self.next_return_action.as_mut().map(|action| &mut action.action) + } + + fn add_expect_revert(&mut self, reason: Option>, depth: usize) { + if self.current_expect_revert().is_some() { + panic!("expectRevert already set") + } + + //-1: Because we are working with return opcode and it pops the stack after execution + let action = ActionOnReturn::ExpectRevert { + reason, + depth: depth - 1, + prev_exception_handler_pc: None, + prev_continue_pc: None, + }; + + // We have to skip at least one return from CHEATCODES contract + self.next_return_action = + Some(NextReturnAction { target_depth: depth - 1, action, returns_to_skip: 1 }); + } + + fn handle_except_revert( + reason: Option<&Vec>, + op: zkevm_opcode_defs::RetOpcode, + state: &VmLocalStateData<'_>, + memory: &SimpleMemory, + ) -> Result<(), Vec> { + match (op, reason) { + (zkevm_opcode_defs::RetOpcode::Revert, Some(expected_reason)) => { + let retdata = { + let ptr = state.vm_local_state.registers + [RET_IMPLICIT_RETURNDATA_PARAMS_REGISTER as usize]; + assert!(ptr.is_pointer); + let fat_data_pointer = FatPointer::from_u256(ptr.value); + memory.read_unaligned_bytes( + fat_data_pointer.memory_page as usize, + fat_data_pointer.start as usize, + fat_data_pointer.length as usize, + ) + }; + + tracing::debug!(?expected_reason, ?retdata); + if !expected_reason.is_empty() && retdata.is_empty() { + return Err("call reverted as expected, but without data".to_string().into()) + } + + match VmRevertReason::from(retdata.as_slice()) { + VmRevertReason::General { msg, data: _ } => { + let expected_reason = String::from_utf8_lossy(expected_reason).to_string(); + if msg == expected_reason { + Ok(()) + } else { + Err(format!( + "Error != expected error: {} != {}", + &msg, expected_reason, + ) + .into()) + } + } + VmRevertReason::Unknown { function_selector: _, data } => { + if &data == expected_reason { + Ok(()) + } else { + Err(format!( + "Error != expected error: {:?} != {:?}", + &data, expected_reason, + ) + .into()) + } + } + _ => { + tracing::error!("unexpected revert reason"); + Err("unexpected revert reason".to_string().into()) + } + } + } + (zkevm_opcode_defs::RetOpcode::Revert, None) => { + tracing::debug!("any revert accepted"); + Ok(()) + } + (zkevm_opcode_defs::RetOpcode::Ok, _) => { + tracing::debug!("expected revert but call succeeded"); + Err("expected revert but call succeeded".to_string().into()) + } + (zkevm_opcode_defs::RetOpcode::Panic, _) => { + tracing::error!("Vm panicked it should have never happened"); + Err("expected revert but call Panicked".to_string().into()) + } + } + } + /// Adds an expectCall to the tracker. - #[allow(clippy::too_many_arguments)] fn expect_call( &mut self, callee: &H160, @@ -1402,6 +1633,77 @@ impl CheatcodeTracer { } } } + + fn handle_return( + &mut self, + state: &VmLocalStateData<'_>, + data: &AfterExecutionData, + memory: &SimpleMemory, + ) { + // Skip check if there are no expected actions + let Some(action) = self.next_return_action.as_mut() else { return }; + // We only care about the certain depth + let callstack_depth = state.vm_local_state.callstack.depth(); + if callstack_depth != action.target_depth { + return + } + + // Skip check if opcode is not Ret + let Opcode::Ret(op) = data.opcode.variant.opcode else { return }; + // Check how many retunrs we need to skip before finding the actual one + if action.returns_to_skip != 0 { + action.returns_to_skip -= 1; + return + } + + // The desired return opcode was found + let ActionOnReturn::ExpectRevert { + reason, + depth, + prev_exception_handler_pc: exception_handler, + prev_continue_pc: continue_pc, + } = &action.action; + match op { + RetOpcode::Revert => { + tracing::debug!(wanted = %depth, current_depth = %callstack_depth, opcode = ?data.opcode.variant.opcode, "expectRevert"); + let (Some(exception_handler), Some(continue_pc)) = + (*exception_handler, *continue_pc) + else { + tracing::error!("exceptRevert missing stored continuations"); + return + }; + + self.one_time_actions.push( + Self::handle_except_revert(reason.as_ref(), op, state, memory) + .map(|_| FinishCycleOneTimeActions::ForceReturn { + //dummy data + data: // vec![0u8; 8192] + [0xde, 0xad, 0xbe, 0xef].to_vec(), + continue_pc, + }) + .unwrap_or_else(|error| FinishCycleOneTimeActions::ForceRevert { + error, + exception_handler, + }), + ); + self.next_return_action = None; + } + RetOpcode::Ok => { + let Some(exception_handler) = *exception_handler else { + tracing::error!("exceptRevert missing stored continuations"); + return + }; + if let Err(err) = Self::handle_except_revert(reason.as_ref(), op, state, memory) { + self.one_time_actions.push(FinishCycleOneTimeActions::ForceRevert { + error: err, + exception_handler, + }); + } + self.next_return_action = None; + } + RetOpcode::Panic => (), + } + } } fn into_revm_env(env: &EraEnv) -> Env { diff --git a/crates/era-cheatcodes/tests/src/cheatcodes/ExpectRevert.t.sol b/crates/era-cheatcodes/tests/src/cheatcodes/ExpectRevert.t.sol new file mode 100644 index 000000000..f68d5f8a9 --- /dev/null +++ b/crates/era-cheatcodes/tests/src/cheatcodes/ExpectRevert.t.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.18; + +import {Test, console2 as console} from "../../lib/forge-std/src/Test.sol"; +import {Constants} from "./Constants.sol"; + +contract Reverter { + error CustomError(uint256 a); + + function revertWithMessage(string memory message) public pure { + revert(message); + } + + function doNotRevert() public pure {} + + function panic() public pure returns (uint256) { + return uint256(100) - uint256(101); + } + + function revertWithCustomError(uint256 a) public pure { + revert CustomError(a); + } + + function nestedRevert(Reverter inner, string memory message) public pure { + inner.revertWithMessage(message); + } + + function callThenRevert(Dummy dummy, string memory message) public pure { + dummy.callMe(); + revert(message); + } + + function revertWithoutReason() public pure { + revert(); + } +} + +contract ConstructorReverter { + constructor(string memory message) { + revert(message); + } +} + +/// Used to ensure that the dummy data from `cheatcodes.expectRevert` +/// is large enough to decode big structs. +/// +/// The struct is based on issue #2454 +struct LargeDummyStruct { + address a; + uint256 b; + bool c; + address d; + address e; + string f; + address[8] g; + address h; + uint256 i; +} + +contract Dummy { + function callMe() public pure returns (string memory) { + return "thanks for calling"; + } + + function largeReturnType() public pure returns (LargeDummyStruct memory) { + revert("reverted with large return type"); + } +} + +contract ExpectRevertTest is Test { + function shouldRevert() internal { + revert(); + } + + function testExpectRevertString() public { + Reverter reverter = new Reverter(); + (bool success, ) = Constants.CHEATCODE_ADDRESS.call( + abi.encodeWithSignature( + "expectRevert(bytes)", + "revert")); + require(success, "expectRevert failed"); + reverter.revertWithMessage("revert"); + } + + function testFailExpectRevertWrongString() public { + Reverter reverter = new Reverter(); + (bool success, ) = Constants.CHEATCODE_ADDRESS.call( + abi.encodeWithSignature( + "expectRevert(bytes)", + "my not so cool error")); + require(success, "expectRevert failed"); + reverter.revertWithMessage("my cool error"); + } + + function testExpectRevertCustomError() public { + Reverter reverter = new Reverter(); + (bool success, ) = Constants.CHEATCODE_ADDRESS.call( + abi.encodeWithSignature( + "expectRevert(bytes)", + abi.encodeWithSelector(Reverter.CustomError.selector, 1))); + require(success, "expectRevert failed"); + reverter.revertWithCustomError(1); + } + + function testFailExpectRevertCustomError() public { + Reverter reverter = new Reverter(); + (bool success, ) = Constants.CHEATCODE_ADDRESS.call( + abi.encodeWithSignature( + "expectRevert(bytes)", + abi.encodeWithSelector(Reverter.CustomError.selector, 1))); + require(success, "expectRevert failed"); + reverter.revertWithCustomError(2); + } + // function testFailRevertNotOnImmediateNextCall() public { + // Reverter reverter = new Reverter(); + // // expectRevert should only work for the next call. However, + // // we do not immediately revert, so, + // // we fail. + // cheatcodes.expectRevert("revert"); + // reverter.doNotRevert(); + // reverter.revertWithMessage("revert"); + // } + + // function testExpectRevertConstructor() public { + // cheatcodes.expectRevert("constructor revert"); + // new ConstructorReverter("constructor revert"); + // } + + // function testExpectRevertBuiltin() public { + // Reverter reverter = new Reverter(); + // cheatcodes.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); + // reverter.panic(); + // } + + // function testExpectRevertCustomError() public { + // Reverter reverter = new Reverter(); + // cheatcodes.expectRevert(abi.encodePacked(Reverter.CustomError.selector)); + // reverter.revertWithCustomError(); + // } + + // function testExpectRevertNested() public { + // Reverter reverter = new Reverter(); + // Reverter inner = new Reverter(); + // cheatcodes.expectRevert("nested revert"); + // reverter.nestedRevert(inner, "nested revert"); + // } + + // function testExpectRevertCallsThenReverts() public { + // Reverter reverter = new Reverter(); + // Dummy dummy = new Dummy(); + // cheatcodes.expectRevert("called a function and then reverted"); + // reverter.callThenRevert(dummy, "called a function and then reverted"); + // } + + // function testDummyReturnDataForBigType() public { + // Dummy dummy = new Dummy(); + // cheatcodes.expectRevert("reverted with large return type"); + // dummy.largeReturnType(); + // } + + // function testFailExpectRevertErrorDoesNotMatch() public { + // Reverter reverter = new Reverter(); + // cheatcodes.expectRevert("should revert with this message"); + // reverter.revertWithMessage("but reverts with this message"); + // } + + function testExpectRevertDidNotRevert() public returns (bool){ + Reverter reverter = new Reverter(); + address revAddr = address(reverter); + bytes memory reverterFunc = abi.encodeWithSignature("doNotRevert()"); + + bytes memory expectRevert = abi.encodeWithSignature("expectRevert()"); + (bool success, bytes memory data) = Constants.CHEATCODE_ADDRESS.call(expectRevert); + + (success, data) = revAddr.call(reverterFunc); + require(!success, "expectRevert failed"); + + return success; + } + + + function testExpectRevertNoReason() public returns(bool, int) { + Reverter reverter = new Reverter(); + address revAddr = address(reverter); + bytes memory reverterFunc = abi.encodeWithSignature("revertWithoutReason()"); + + bytes memory expectRevert = abi.encodeWithSignature("expectRevert()"); + (bool success, bytes memory data) = Constants.CHEATCODE_ADDRESS.call(expectRevert); + + (success, data) = revAddr.call(reverterFunc); + require(success, "expectRevert failed"); + return (success, 42); + } + + function testExpectRevertMessage() public returns(bool, int) { + Reverter reverter = new Reverter(); + address revAddr = address(reverter); + bytes memory reverterFunc = abi.encodeWithSignature("revertWithMessage(string)", "abcd"); + + bytes memory expectRevert = abi.encodeWithSignature("expectRevert()"); + (bool success, bytes memory data) = Constants.CHEATCODE_ADDRESS.call(expectRevert); + + (success, data) = revAddr.call(reverterFunc); + require(success, "expectRevert failed"); + return (success, 42); + } + + // function testExpectRevertAnyRevert() public { + // cheatcodes.expectRevert(); + // new ConstructorReverter("hello this is a revert message"); + + // Reverter reverter = new Reverter(); + // cheatcodes.expectRevert(); + // reverter.revertWithMessage("this is also a revert message"); + + // cheatcodes.expectRevert(); + // reverter.panic(); + + // cheatcodes.expectRevert(); + // reverter.revertWithCustomError(); + + // Reverter reverter2 = new Reverter(); + // cheatcodes.expectRevert(); + // reverter.nestedRevert(reverter2, "this too is a revert message"); + + // Dummy dummy = new Dummy(); + // cheatcodes.expectRevert(); + // reverter.callThenRevert(dummy, "this as well is a revert message"); + + // cheatcodes.expectRevert(); + // reverter.revertWithoutReason(); + // } + + function testFailExpectRevertAnyRevertDidNotRevert() public { + Reverter reverter = new Reverter(); + (bool success, ) = Constants.CHEATCODE_ADDRESS.call( + abi.encodeWithSignature( + "expectRevert()")); + require(success, "expectRevert failed"); + reverter.doNotRevert(); + } + + // function testFailExpectRevertDangling() public { + // cheatcodes.expectRevert("dangling"); + // } +} \ No newline at end of file