diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 360d5de1c442..3b4d071d7589 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -415,6 +415,37 @@ "description": "If the access was reverted." } ] + }, + { + "name": "Gas", + "description": "", + "fields": [ + { + "name": "gasLimit", + "ty": "uint64", + "description": "" + }, + { + "name": "gasTotalUsed", + "ty": "uint64", + "description": "" + }, + { + "name": "gasMemoryUsed", + "ty": "uint64", + "description": "" + }, + { + "name": "gasRefunded", + "ty": "int64", + "description": "" + }, + { + "name": "gasRemaining", + "ty": "uint64", + "description": "" + } + ] } ], "cheatcodes": [ @@ -4958,6 +4989,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "lastCallGas", + "description": "Gets the gas used in the last call.", + "declaration": "function lastCallGas() external view returns (Gas memory gas);", + "visibility": "external", + "mutability": "view", + "signature": "lastCallGas()", + "selector": "0x2b589b28", + "selectorBytes": [ + 43, + 88, + 155, + 40 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "load", diff --git a/crates/cheatcodes/spec/src/lib.rs b/crates/cheatcodes/spec/src/lib.rs index 10a53e18d874..16bb60834068 100644 --- a/crates/cheatcodes/spec/src/lib.rs +++ b/crates/cheatcodes/spec/src/lib.rs @@ -83,6 +83,7 @@ impl Cheatcodes<'static> { Vm::ChainInfo::STRUCT.clone(), Vm::AccountAccess::STRUCT.clone(), Vm::StorageAccess::STRUCT.clone(), + Vm::Gas::STRUCT.clone(), ]), enums: Cow::Owned(vec![ Vm::CallerMode::ENUM.clone(), diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index 93042c1991ff..c350690fcc26 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -73,6 +73,19 @@ interface Vm { address emitter; } + struct Gas { + // The gas limit of the call. + uint64 gasLimit; + // The total gas used. + uint64 gasTotalUsed; + // The amount of gas used for memory expansion. + uint64 gasMemoryUsed; + // The amount of gas refunded. + int64 gasRefunded; + // The amount of gas remaining. + uint64 gasRemaining; + } + /// An RPC URL and its alias. Returned by `rpcUrlStructs`. struct Rpc { /// The alias of the RPC URL. @@ -169,6 +182,22 @@ interface Vm { uint256 chainId; } + /// The storage accessed during an `AccountAccess`. + struct StorageAccess { + /// The account whose storage was accessed. + address account; + /// The slot that was accessed. + bytes32 slot; + /// If the access was a write. + bool isWrite; + /// The previous value of the slot. + bytes32 previousValue; + /// The new value of the slot. + bytes32 newValue; + /// If the access was reverted. + bool reverted; + } + /// The result of a `stopAndReturnStateDiff` call. struct AccountAccess { /// The chain and fork the access occurred. @@ -207,22 +236,6 @@ interface Vm { uint64 depth; } - /// The storage accessed during an `AccountAccess`. - struct StorageAccess { - /// The account whose storage was accessed. - address account; - /// The slot that was accessed. - bytes32 slot; - /// If the access was a write. - bool isWrite; - /// The previous value of the slot. - bytes32 previousValue; - /// The new value of the slot. - bytes32 newValue; - /// If the access was reverted. - bool reverted; - } - // ======== EVM ======== /// Gets the address for a given private key. @@ -594,6 +607,7 @@ interface Vm { function getRecordedLogs() external returns (Log[] memory logs); // -------- Gas Metering -------- + // It's recommend to use the `noGasMetering` modifier included with forge-std, instead of // using these functions directly. @@ -605,6 +619,12 @@ interface Vm { #[cheatcode(group = Evm, safety = Safe)] function resumeGasMetering() external; + // -------- Gas Measurement -------- + + /// Gets the gas used in the last call. + #[cheatcode(group = Evm, safety = Safe)] + function lastCallGas() external view returns (Gas memory gas); + // ======== Test Assertions and Utilities ======== /// If the condition is false, discard this run's fuzz inputs and generate new ones. diff --git a/crates/cheatcodes/src/evm.rs b/crates/cheatcodes/src/evm.rs index 2f3dafedefe6..d7328fcc0ea7 100644 --- a/crates/cheatcodes/src/evm.rs +++ b/crates/cheatcodes/src/evm.rs @@ -224,6 +224,19 @@ impl Cheatcode for resumeGasMeteringCall { } } +impl Cheatcode for lastCallGasCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self {} = self; + ensure!(state.last_call_gas.is_some(), "`lastCallGas` is only available after a call"); + Ok(state + .last_call_gas + .as_ref() + // This should never happen, as we ensure `last_call_gas` is `Some` above. + .expect("`lastCallGas` is only available after a call") + .abi_encode()) + } +} + impl Cheatcode for chainIdCall { fn apply_full(&self, ccx: &mut CheatsCtxt) -> Result { let Self { newChainId } = self; diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 699840d9325a..934cae0c75d9 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -145,6 +145,10 @@ pub struct Cheatcodes { /// Recorded logs pub recorded_logs: Option>, + /// Cache of the amount of gas used in previous call. + /// This is used by the `lastCallGas` cheatcode. + pub last_call_gas: Option, + /// Mocked calls // **Note**: inner must a BTreeMap because of special `Ord` impl for `MockCallDataContext` pub mocked_calls: HashMap>, @@ -1033,9 +1037,25 @@ impl Inspector for Cheatcodes { // Exit early for calls to cheatcodes as other logic is not relevant for cheatcode // invocations if cheatcode_call { - return outcome; + return outcome } + // Record the gas usage of the call, this allows the `lastCallGas` cheatcode to + // retrieve the gas usage of the last call. + let gas = outcome.result.gas; + self.last_call_gas = Some(crate::Vm::Gas { + // The gas limit of the call. + gasLimit: gas.limit(), + // The total gas used. + gasTotalUsed: gas.spend(), + // The amount of gas used for memory expansion. + gasMemoryUsed: gas.memory(), + // The amount of gas refunded. + gasRefunded: gas.refunded(), + // The amount of gas remaining. + gasRemaining: gas.remaining(), + }); + // If `startStateDiffRecording` has been called, update the `reverted` status of the // previous call depth's recorded accesses, if any if let Some(recorded_account_diffs_stack) = &mut self.recorded_account_diffs_stack { diff --git a/crates/forge/tests/it/cheats.rs b/crates/forge/tests/it/cheats.rs index 920dd8f2d2c1..42113cdc770d 100644 --- a/crates/forge/tests/it/cheats.rs +++ b/crates/forge/tests/it/cheats.rs @@ -7,10 +7,11 @@ use crate::{ use foundry_config::{fs_permissions::PathPermission, FsPermissions}; use foundry_test_utils::Filter; -/// Executes all cheat code tests but not fork cheat codes +/// Executes all cheat code tests but not fork cheat codes or tests that require isolation mode async fn test_cheats_local(test_data: &ForgeTestData) { - let mut filter = - Filter::new(".*", ".*", &format!(".*cheats{RE_PATH_SEPARATOR}*")).exclude_paths("Fork"); + let mut filter = Filter::new(".*", ".*", &format!(".*cheats{RE_PATH_SEPARATOR}*")) + .exclude_paths("Fork") + .exclude_contracts("Isolated"); // Exclude FFI tests on Windows because no `echo`, and file tests that expect certain file paths if cfg!(windows) { @@ -24,11 +25,27 @@ async fn test_cheats_local(test_data: &ForgeTestData) { TestConfig::with_filter(runner, filter).run().await; } +/// Executes subset of all cheat code tests in isolation mode +async fn test_cheats_local_isolated(test_data: &ForgeTestData) { + let filter = Filter::new(".*", ".*(Isolated)", &format!(".*cheats{RE_PATH_SEPARATOR}*")); + + let mut config = test_data.config.clone(); + config.isolate = true; + let runner = test_data.runner_with_config(config); + + TestConfig::with_filter(runner, filter).run().await; +} + #[tokio::test(flavor = "multi_thread")] async fn test_cheats_local_default() { test_cheats_local(&TEST_DATA_DEFAULT).await } +#[tokio::test(flavor = "multi_thread")] +async fn test_cheats_local_default_isolated() { + test_cheats_local_isolated(&TEST_DATA_DEFAULT).await +} + #[tokio::test(flavor = "multi_thread")] async fn test_cheats_local_multi_version() { test_cheats_local(&TEST_DATA_MULTI_VERSION).await diff --git a/crates/forge/tests/it/test_helpers.rs b/crates/forge/tests/it/test_helpers.rs index a8ab8229c4bf..6fc8a3745f15 100644 --- a/crates/forge/tests/it/test_helpers.rs +++ b/crates/forge/tests/it/test_helpers.rs @@ -203,7 +203,12 @@ impl ForgeTestData { config.prompt_timeout = 0; let root = self.project.root(); - let opts = self.evm_opts.clone(); + let mut opts = self.evm_opts.clone(); + + if config.isolate { + opts.isolate = true; + } + let env = opts.local_evm_env(); let output = self.output.clone(); let artifact_ids = output.artifact_ids().map(|(id, _)| id).collect(); @@ -215,6 +220,7 @@ impl ForgeTestData { None, None, )) + .enable_isolation(opts.isolate) .sender(config.sender) .with_test_options(self.test_opts.clone()) .build(root, output, env, opts.clone()) diff --git a/crates/test-utils/src/filter.rs b/crates/test-utils/src/filter.rs index fb07237f22af..e24f87c17523 100644 --- a/crates/test-utils/src/filter.rs +++ b/crates/test-utils/src/filter.rs @@ -7,6 +7,7 @@ pub struct Filter { contract_regex: Regex, path_regex: Regex, exclude_tests: Option, + exclude_contracts: Option, exclude_paths: Option, } @@ -21,6 +22,7 @@ impl Filter { path_regex: Regex::new(path_pattern) .unwrap_or_else(|_| panic!("Failed to parse path pattern: `{path_pattern}`")), exclude_tests: None, + exclude_contracts: None, exclude_paths: None, } } @@ -41,6 +43,14 @@ impl Filter { self } + /// All contracts to also exclude + /// + /// This is a workaround since regex does not support negative look aheads + pub fn exclude_contracts(mut self, pattern: &str) -> Self { + self.exclude_contracts = Some(Regex::new(pattern).unwrap()); + self + } + /// All paths to also exclude /// /// This is a workaround since regex does not support negative look aheads @@ -55,6 +65,7 @@ impl Filter { contract_regex: Regex::new(".*").unwrap(), path_regex: Regex::new(".*").unwrap(), exclude_tests: None, + exclude_contracts: None, exclude_paths: None, } } @@ -71,6 +82,12 @@ impl TestFilter for Filter { } fn matches_contract(&self, contract_name: &str) -> bool { + if let Some(exclude) = &self.exclude_contracts { + if exclude.is_match(contract_name) { + return false; + } + } + self.contract_regex.is_match(contract_name) } diff --git a/testdata/cheats/Vm.sol b/testdata/cheats/Vm.sol index aff05c278740..1d7c81973847 100644 --- a/testdata/cheats/Vm.sol +++ b/testdata/cheats/Vm.sol @@ -18,6 +18,7 @@ interface Vm { struct ChainInfo { uint256 forkId; uint256 chainId; } struct AccountAccess { ChainInfo chainInfo; AccountAccessKind kind; address account; address accessor; bool initialized; uint256 oldBalance; uint256 newBalance; bytes deployedCode; uint256 value; bytes data; bool reverted; StorageAccess[] storageAccesses; uint64 depth; } struct StorageAccess { address account; bytes32 slot; bool isWrite; bytes32 previousValue; bytes32 newValue; bool reverted; } + struct Gas { uint64 gasLimit; uint64 gasTotalUsed; uint64 gasMemoryUsed; int64 gasRefunded; uint64 gasRemaining; } function _expectCheatcodeRevert() external; function _expectCheatcodeRevert(bytes4 revertData) external; function _expectCheatcodeRevert(bytes calldata revertData) external; @@ -245,6 +246,7 @@ interface Vm { function keyExistsJson(string calldata json, string calldata key) external view returns (bool); function keyExistsToml(string calldata toml, string calldata key) external view returns (bool); function label(address account, string calldata newLabel) external; + function lastCallGas() external view returns (Gas memory gas); function load(address target, bytes32 slot) external view returns (bytes32 data); function loadAllocs(string calldata pathToAllocsJson) external; function makePersistent(address account) external; diff --git a/testdata/default/cheats/LastCallGas.t.sol b/testdata/default/cheats/LastCallGas.t.sol new file mode 100644 index 000000000000..ec8c6ba0aad5 --- /dev/null +++ b/testdata/default/cheats/LastCallGas.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity 0.8.18; + +import "ds-test/test.sol"; +import "cheats/Vm.sol"; + +contract Target { + uint256 public slot0; + + function expandMemory(uint256 n) public pure returns (uint256) { + uint256[] memory arr = new uint256[](n); + + for (uint256 i = 0; i < n; i++) { + arr[i] = i; + } + + return arr.length; + } + + function setValue(uint256 value) public { + slot0 = value; + } + + function resetValue() public { + slot0 = 0; + } + + fallback() external {} +} + +abstract contract LastCallGasFixture is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + Target public target; + + struct Gas { + uint64 gasTotalUsed; + uint64 gasMemoryUsed; + int64 gasRefunded; + } + + function testRevertNoCachedLastCallGas() public { + vm.expectRevert(); + vm.lastCallGas(); + } + + function _setup() internal { + // Cannot be set in `setUp` due to `testRevertNoCachedLastCallGas` + // relying on no calls being made before `lastCallGas` is called. + target = new Target(); + } + + function _performCall() internal returns (bool success) { + (success,) = address(target).call(""); + } + + function _performExpandMemory() internal view { + target.expandMemory(1000); + } + + function _performRefund() internal { + target.setValue(1); + target.resetValue(); + } + + function _assertGas(Vm.Gas memory lhs, Gas memory rhs) internal { + assertGt(lhs.gasLimit, 0); + assertGt(lhs.gasRemaining, 0); + assertEq(lhs.gasTotalUsed, rhs.gasTotalUsed); + assertEq(lhs.gasMemoryUsed, rhs.gasMemoryUsed); + assertEq(lhs.gasRefunded, rhs.gasRefunded); + } +} + +contract LastCallGasIsolatedTest is LastCallGasFixture { + function testRecordLastCallGas() public { + _setup(); + _performCall(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 21064, gasMemoryUsed: 0, gasRefunded: 0})); + + _performCall(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 21064, gasMemoryUsed: 0, gasRefunded: 0})); + + _performCall(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 21064, gasMemoryUsed: 0, gasRefunded: 0})); + } + + function testRecordGasMemory() public { + _setup(); + _performExpandMemory(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 186470, gasMemoryUsed: 4994, gasRefunded: 0})); + } + + function testRecordGasRefund() public { + _setup(); + _performRefund(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 21380, gasMemoryUsed: 0, gasRefunded: 4800})); + } +} + +// Without isolation mode enabled the gas usage will be incorrect. +contract LastCallGasDefaultTest is LastCallGasFixture { + function testRecordLastCallGas() public { + _setup(); + _performCall(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 64, gasMemoryUsed: 9, gasRefunded: 0})); + + _performCall(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 64, gasMemoryUsed: 9, gasRefunded: 0})); + + _performCall(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 64, gasMemoryUsed: 9, gasRefunded: 0})); + } + + function testRecordGasMemory() public { + _setup(); + _performExpandMemory(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 186470, gasMemoryUsed: 4994, gasRefunded: 0})); + } + + function testRecordGasRefund() public { + _setup(); + _performRefund(); + _assertGas(vm.lastCallGas(), Gas({gasTotalUsed: 216, gasMemoryUsed: 9, gasRefunded: 19900})); + } +}