From d73cbccb708c83570b7768194dfbc3e51f59ad94 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:28:38 -0400 Subject: [PATCH 1/4] feat: add multicall to simulator --- snapshots/BenchmarkTest.json | 102 ++++---- src/Simulator.sol | 226 +++++++++++++++++ src/interfaces/IMulticall3.sol | 38 +++ test/SimulateExecute.t.sol | 436 +++++++++++++++++++++++++++++++++ 4 files changed, 751 insertions(+), 51 deletions(-) create mode 100644 src/interfaces/IMulticall3.sol diff --git a/snapshots/BenchmarkTest.json b/snapshots/BenchmarkTest.json index 82f7d2ae..dedb663c 100644 --- a/snapshots/BenchmarkTest.json +++ b/snapshots/BenchmarkTest.json @@ -1,53 +1,53 @@ { - "testERC20Transfer_AlchemyModularAccount": "179494", - "testERC20Transfer_AlchemyModularAccount_AppSponsor": "176436", - "testERC20Transfer_AlchemyModularAccount_ERC20SelfPay": "207779", - "testERC20Transfer_Batch100_AlchemyModularAccount_AppSponsor": "8897167", - "testERC20Transfer_Batch100_CoinbaseSmartWallet": "9952203", - "testERC20Transfer_Batch100_CoinbaseSmartWallet_AppSponsor": "8787327", - "testERC20Transfer_Batch100_CoinbaseSmartWallet_ERC20SelfPay": "11335605", - "testERC20Transfer_Batch100_Safe4337": "11685484", - "testERC20Transfer_Batch100_Safe4337_AppSponsor": "10198375", - "testERC20Transfer_Batch100_Safe4337_ERC20SelfPay": "12757523", - "testERC20Transfer_Batch100_ZerodevKernel_AppSponsor": "11427583", - "testERC20Transfer_CoinbaseSmartWallet": "177855", - "testERC20Transfer_CoinbaseSmartWallet_AppSponsor": "175259", - "testERC20Transfer_CoinbaseSmartWallet_ERC20SelfPay": "204919", - "testERC20Transfer_ERC4337MinimalAccount": "171509", - "testERC20Transfer_ERC4337MinimalAccount_AppSponsor": "168500", - "testERC20Transfer_ERC4337MinimalAccount_ERC20SelfPay": "199831", - "testERC20Transfer_IthacaAccount": "128195", - "testERC20Transfer_IthacaAccountWithSpendLimits": "193658", - "testERC20Transfer_IthacaAccount_AppSponsor": "138709", - "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "144010", - "testERC20Transfer_IthacaAccount_ERC20SelfPay": "128684", - "testERC20Transfer_Safe4337": "197561", - "testERC20Transfer_Safe4337_AppSponsor": "191725", - "testERC20Transfer_Safe4337_ERC20SelfPay": "221464", - "testERC20Transfer_ZerodevKernel": "207117", - "testERC20Transfer_ZerodevKernel_AppSponsor": "204120", - "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "235489", - "testERC20Transfer_batch100_AlchemyModularAccount": "10109066", - "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "11609298", - "testERC20Transfer_batch100_IthacaAccount": "7545392", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "8151496", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "7977508", - "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "7366652", - "testERC20Transfer_batch100_ZerodevKernel": "12631318", - "testERC20Transfer_batch100_ZerodevKernel_ERC20SelfPay": "14149937", - "testNativeTransfer_AlchemyModularAccount": "180829", - "testNativeTransfer_CoinbaseSmartWallet": "178916", - "testNativeTransfer_IthacaAccount": "129551", - "testNativeTransfer_IthacaAccount_AppSponsor": "140096", - "testNativeTransfer_IthacaAccount_ERC20SelfPay": "137340", - "testNativeTransfer_Safe4337": "198595", - "testNativeTransfer_ZerodevKernel": "208635", - "testUniswapV2Swap_AlchemyModularAccount": "238647", - "testUniswapV2Swap_CoinbaseSmartWallet": "237451", - "testUniswapV2Swap_ERC4337MinimalAccount": "230691", - "testUniswapV2Swap_IthacaAccount": "187339", - "testUniswapV2Swap_IthacaAccount_AppSponsor": "197817", - "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "192628", - "testUniswapV2Swap_Safe4337": "257333", - "testUniswapV2Swap_ZerodevKernel": "266367" + "testERC20Transfer_AlchemyModularAccount": "159052", + "testERC20Transfer_AlchemyModularAccount_AppSponsor": "134278", + "testERC20Transfer_AlchemyModularAccount_ERC20SelfPay": "160169", + "testERC20Transfer_Batch100_AlchemyModularAccount_AppSponsor": "7053863", + "testERC20Transfer_Batch100_CoinbaseSmartWallet": "9801043", + "testERC20Transfer_Batch100_CoinbaseSmartWallet_AppSponsor": "6483523", + "testERC20Transfer_Batch100_CoinbaseSmartWallet_ERC20SelfPay": "8487729", + "testERC20Transfer_Batch100_Safe4337": "11165400", + "testERC20Transfer_Batch100_Safe4337_AppSponsor": "7525827", + "testERC20Transfer_Batch100_Safe4337_ERC20SelfPay": "9540939", + "testERC20Transfer_Batch100_ZerodevKernel_AppSponsor": "8956539", + "testERC20Transfer_CoinbaseSmartWallet": "150133", + "testERC20Transfer_CoinbaseSmartWallet_AppSponsor": "126009", + "testERC20Transfer_CoinbaseSmartWallet_ERC20SelfPay": "150241", + "testERC20Transfer_ERC4337MinimalAccount": "148602", + "testERC20Transfer_ERC4337MinimalAccount_AppSponsor": "123885", + "testERC20Transfer_ERC4337MinimalAccount_ERC20SelfPay": "149776", + "testERC20Transfer_IthacaAccount": "91666", + "testERC20Transfer_IthacaAccountWithSpendLimits": "113167", + "testERC20Transfer_IthacaAccount_AppSponsor": "99218", + "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "104319", + "testERC20Transfer_IthacaAccount_ERC20SelfPay": "91967", + "testERC20Transfer_Safe4337": "163675", + "testERC20Transfer_Safe4337_AppSponsor": "136323", + "testERC20Transfer_Safe4337_ERC20SelfPay": "160610", + "testERC20Transfer_ZerodevKernel": "175447", + "testERC20Transfer_ZerodevKernel_AppSponsor": "150734", + "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "176663", + "testERC20Transfer_batch100_AlchemyModularAccount": "10438358", + "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "9221814", + "testERC20Transfer_batch100_IthacaAccount": "6201928", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "6758228", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "6565428", + "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "6004328", + "testERC20Transfer_batch100_ZerodevKernel": "12332690", + "testERC20Transfer_batch100_ZerodevKernel_ERC20SelfPay": "11134785", + "testNativeTransfer_AlchemyModularAccount": "168453", + "testNativeTransfer_CoinbaseSmartWallet": "159248", + "testNativeTransfer_IthacaAccount": "101064", + "testNativeTransfer_IthacaAccount_AppSponsor": "108635", + "testNativeTransfer_IthacaAccount_ERC20SelfPay": "101365", + "testNativeTransfer_Safe4337": "172763", + "testNativeTransfer_ZerodevKernel": "184855", + "testUniswapV2Swap_AlchemyModularAccount": "210111", + "testUniswapV2Swap_CoinbaseSmartWallet": "201623", + "testUniswapV2Swap_ERC4337MinimalAccount": "199690", + "testUniswapV2Swap_IthacaAccount": "142728", + "testUniswapV2Swap_IthacaAccount_AppSponsor": "150244", + "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "143029", + "testUniswapV2Swap_Safe4337": "215353", + "testUniswapV2Swap_ZerodevKernel": "226591" } \ No newline at end of file diff --git a/src/Simulator.sol b/src/Simulator.sol index f70b05f4..f195564c 100644 --- a/src/Simulator.sol +++ b/src/Simulator.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.23; import {ICommon} from "./interfaces/ICommon.sol"; +import {IMulticall3} from "./interfaces/IMulticall3.sol"; import {FixedPointMathLib as Math} from "solady/utils/FixedPointMathLib.sol"; /// @title Simulator @@ -125,6 +126,89 @@ contract Simulator { return _callOrchestrator(oc, isStateOverride, data); } + /// @dev Performs a call to Multicall3 with pre-calls followed by an orchestrator call. + /// Returns the gas used parsed from the last Result in the multicall3 response. + /// If parsing fails (gasUsed == 0), this function stores the orchestrator's error in memory + /// so the caller can bubble it up using bubbleUpMulticall3Error. + /// @param multicall3 The multicall3 contract address + /// @param preCalls Array of Call3 structs representing calls to execute before the orchestrator call + /// @param oc The orchestrator address + /// @param isStateOverride Whether to use state override mode for the orchestrator call + /// @param combinedGasOverride The combined gas override value + /// @param u The Intent struct to pass to the orchestrator + function _callMulticall3( + address multicall3, + IMulticall3.Call3[] memory preCalls, + address oc, + bool isStateOverride, + uint256 combinedGasOverride, + ICommon.Intent memory u + ) internal freeTempMemory returns (uint256 gasUsed, bytes memory lastReturnData) { + // Build the orchestrator call data + bytes memory orchestratorData = abi.encodeWithSignature( + "simulateExecute(bool,uint256,bytes)", + isStateOverride, + combinedGasOverride, + abi.encode(u) + ); + + // Construct the full Call3[] array: preCalls + orchestrator call + IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](preCalls.length + 1); + for (uint256 i = 0; i < preCalls.length; i++) { + calls[i] = preCalls[i]; + } + // Last call is to the orchestrator + calls[preCalls.length] = + IMulticall3.Call3({target: oc, allowFailure: true, callData: orchestratorData}); + + // Call multicall3.aggregate3 + IMulticall3.Result[] memory results = IMulticall3(multicall3).aggregate3(calls); + + // Get the last result (orchestrator call result) + if (results.length > 1) { + for (uint256 i = 0; i < results.length - 2; i++) { + // If any pre-call failed, we return gasUsed = 0 and the error data from that call + if (!results[i].success) { + return (0, results[i].returnData); + } + } + } + IMulticall3.Result memory lastResult = results[results.length - 1]; + lastReturnData = lastResult.returnData; + + // Parse gasUsed from the orchestrator's return data + // Assembly required for low-level parsing of return data + assembly ("memory-safe") { + let returnDataPtr := add(lastReturnData, 0x20) + + switch isStateOverride + case 0 { + // If `isStateOverride` is false, the orchestrator reverts with SimulationPassed + // so success will be false in multicall3's Result, but we still need to parse the revert data + // Check for SimulationPassed selector (0x4f0c028c) + // The `gasUsed` will be in the revert data at offset 0x04 + if eq(shr(224, mload(returnDataPtr)), 0x4f0c028c) { + gasUsed := mload(add(returnDataPtr, 0x04)) + } + } + default { + // If isStateOverride is true and call succeeded, gasUsed is at offset 0x00 + let lastSuccess := mload(lastResult) + if lastSuccess { + gasUsed := mload(returnDataPtr) + } + } + } + } + + /// @dev Bubbles up the error from the multicall3's last result returnData. + /// This is called when _callMulticall3 returns gasUsed == 0. + function _bubbleUpMulticall3Error(bytes memory errorData) internal pure { + assembly ("memory-safe") { + revert(add(errorData, 0x20), mload(errorData)) + } + } + /// @dev Simulate the gas usage for a user operation. This function reverts if the simulation fails. /// @param oc The orchestrator address /// @param overrideCombinedGas Whether to override the combined gas for the intent to type(uint256).max @@ -262,4 +346,146 @@ contract Simulator { } } } + + /// @dev Simulates the execution of an intent through Multicall3, with pre-calls executed before the orchestrator call. + /// Finds the combined gas by iteratively increasing it until the simulation passes. + /// The start value for combinedGas is gasUsed + original combinedGas. + /// Set u.combinedGas to add some starting offset to the gasUsed value. + /// @param multicall3 The multicall3 contract address + /// @param preCalls Array of Call3 structs representing calls to execute before the orchestrator call + /// @param oc The orchestrator address + /// @param paymentPerGasPrecision The precision of the payment per gas value. + /// paymentAmount = gas * paymentPerGas / (10 ** paymentPerGasPrecision) + /// @param paymentPerGas The amount of `paymentToken` to be added per gas unit. + /// Total payment is calculated as paymentAmount += gasUsed * paymentPerGas. + /// @dev Set paymentAmount to include any static offset to the gas value. + /// @param combinedGasIncrement Basis Points increment to be added for each iteration of searching for combined gas. + /// @dev The closer this number is to 10_000, the more precise combined gas will be. But more iterations will be needed. + /// @dev This number should always be > 10_000, to get correct results. + /// If the increment is too small, the function might run out of gas while finding the combined gas value. + /// @param encodedIntent The encoded user operation + /// @return gasUsed The gas used in the successful simulation + /// @return combinedGas The first combined gas value that gives a successful simulation. + /// This function reverts if the primary simulation run with max combinedGas fails. + /// If the primary run is successful, it iteratively increases u.combinedGas by `combinedGasIncrement` until the simulation passes. + /// All failing simulations during this run are ignored. + function simulateMulticall3CombinedGas( + address multicall3, + IMulticall3.Call3[] memory preCalls, + address oc, + uint8 paymentPerGasPrecision, + uint256 paymentPerGas, + uint256 combinedGasIncrement, + bytes calldata encodedIntent + ) public payable virtual returns (uint256 gasUsed, uint256 combinedGas) { + // Decode the intent first + ICommon.Intent memory u = abi.decode(encodedIntent, (ICommon.Intent)); + + bytes memory errorData; + + // 1. Primary Simulation Run to get initial gasUsed value with combinedGasOverride + + (gasUsed, errorData) = + _callMulticall3(multicall3, preCalls, oc, false, type(uint256).max, u); + + // If the simulation failed, bubble up the orchestrator's error. + if (gasUsed == 0) { + _bubbleUpMulticall3Error(errorData); + } + + u.combinedGas += gasUsed; + + _updatePaymentAmounts(u, u.combinedGas, paymentPerGasPrecision, paymentPerGas); + + while (true) { + (gasUsed, errorData) = _callMulticall3(multicall3, preCalls, oc, false, 0, u); + + // If the simulation failed, check if it's a PaymentError and bubble it up. + // PaymentError is given special treatment here, as it comes from + // the account not having enough funds, and cannot be recovered from, + // since the paymentAmount will keep increasing in this loop. + if (gasUsed == 0 && errorData.length >= 4) { + // errorData is a bytes memory containing the revert data from the orchestrator + // Layout: [length (32 bytes)][selector (4 bytes)][additional data...] + bytes4 errorSelector; + assembly ("memory-safe") { + // Load 32 bytes starting from errorData+32, then shift right to get first 4 bytes + errorSelector := mload(add(errorData, 32)) + } + if (errorSelector == 0xabab8fc9) { // PaymentError() + + // Revert with just the selector (0x20 bytes = 4 bytes selector + 28 bytes padding) + assembly ("memory-safe") { + mstore(0x00, errorSelector) + revert(0x00, 0x20) + } + } + } + + if (gasUsed != 0) { + return (gasUsed, u.combinedGas); + } + + uint256 gasIncrement = Math.mulDiv(u.combinedGas, combinedGasIncrement, 10_000); + + _updatePaymentAmounts(u, gasIncrement, paymentPerGasPrecision, paymentPerGas); + + // Step up the combined gas, until we see a simulation passing + u.combinedGas += gasIncrement; + } + } + + /// @dev Same as simulateMulticall3CombinedGas, but with an additional verification run + /// that generates a successful non reverting state override simulation. + /// Which can be used in eth_simulateV1 to get the trace. + /// @param multicall3 The multicall3 contract address + /// @param preCalls Array of Call3 structs representing calls to execute before the orchestrator call + /// @param oc The orchestrator address + /// @param paymentPerGasPrecision The precision of the payment per gas value. + /// paymentAmount = gas * paymentPerGas / (10 ** paymentPerGasPrecision) + /// @param paymentPerGas The amount of `paymentToken` to be added per gas unit. + /// @param combinedGasIncrement Basis Points increment to be added for each iteration of searching for combined gas. + /// @param combinedGasVerificationOffset is a static value that is added after a successful combinedGas is found. + /// This can be used to account for variations in sig verification gas, for keytypes like P256. + /// @param encodedIntent The encoded user operation + /// @return gasUsed The gas used in the successful simulation + /// @return combinedGas The combined gas value including the verification offset + function simulateMulticall3V1Logs( + address multicall3, + IMulticall3.Call3[] memory preCalls, + address oc, + uint8 paymentPerGasPrecision, + uint256 paymentPerGas, + uint256 combinedGasIncrement, + uint256 combinedGasVerificationOffset, + bytes calldata encodedIntent + ) public payable virtual returns (uint256 gasUsed, uint256 combinedGas) { + (gasUsed, combinedGas) = simulateMulticall3CombinedGas( + multicall3, + preCalls, + oc, + paymentPerGasPrecision, + paymentPerGas, + combinedGasIncrement, + encodedIntent + ); + + combinedGas += combinedGasVerificationOffset; + + ICommon.Intent memory u = abi.decode(encodedIntent, (ICommon.Intent)); + + _updatePaymentAmounts(u, combinedGas, paymentPerGasPrecision, paymentPerGas); + + u.combinedGas = combinedGas; + + bytes memory errorData; + + // Verification Run to generate the logs with the correct combinedGas and payment amounts. + (gasUsed, errorData) = _callMulticall3(multicall3, preCalls, oc, true, 0, u); + + // If the simulation failed, bubble up the orchestrator's error + if (gasUsed == 0) { + _bubbleUpMulticall3Error(errorData); + } + } } diff --git a/src/interfaces/IMulticall3.sol b/src/interfaces/IMulticall3.sol new file mode 100644 index 00000000..c0ab86f2 --- /dev/null +++ b/src/interfaces/IMulticall3.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// @notice Interface for Multicall3 contract +interface IMulticall3 { + struct Call { + address target; + bytes callData; + } + + struct Call3 { + address target; + bool allowFailure; + bytes callData; + } + + struct Call3Value { + address target; + bool allowFailure; + uint256 value; + bytes callData; + } + + struct Result { + bool success; + bytes returnData; + } + + function aggregate(Call[] calldata calls) + external + payable + returns (uint256 blockNumber, bytes[] memory returnData); + + function aggregate3(Call3[] calldata calls) + external + payable + returns (Result[] memory returnData); +} diff --git a/test/SimulateExecute.t.sol b/test/SimulateExecute.t.sol index 681266f7..981e2deb 100644 --- a/test/SimulateExecute.t.sol +++ b/test/SimulateExecute.t.sol @@ -4,13 +4,16 @@ pragma solidity ^0.8.4; import "./utils/SoladyTest.sol"; import "./Base.t.sol"; import {MockGasBurner} from "./utils/mocks/MockGasBurner.sol"; +import {IMulticall3} from "../src/interfaces/IMulticall3.sol"; contract SimulateExecuteTest is BaseTest { MockGasBurner gasBurner; + MockMulticall3 multicall3; function setUp() public virtual override { super.setUp(); gasBurner = new MockGasBurner(); + multicall3 = new MockMulticall3(); } struct _SimulateExecuteTemps { @@ -22,6 +25,7 @@ contract SimulateExecuteTest is BaseTest { bytes executionData; bool success; bytes result; + IMulticall3.Call3[] preCalls; } function _gasToBurn() internal returns (uint256) { @@ -318,4 +322,436 @@ contract SimulateExecuteTest is BaseTest { assertEq(oc.execute{gas: t.gExecute}(abi.encode(i)), 0); assertEq(gasBurner.randomness(), t.randomness); } + + function testSimulateMulticall3V1Logs() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + assertEq(_balanceOf(address(paymentToken), d.eoa), 0); + + paymentToken.mint(d.eoa, type(uint128).max); + + _SimulateExecuteTemps memory t; + + gasBurner.setRandomness(1); // Warm the storage first. + + t.gasToBurn = _gasToBurn(); + do { + t.randomness = _randomUniform(); + } while (t.randomness == 0); + emit LogUint("gasToBurn", t.gasToBurn); + + t.executionData = _executionData( + address(gasBurner), + abi.encodeWithSignature("burnGas(uint256,uint256)", t.gasToBurn, t.randomness) + ); + + // Create some preCalls + t.preCalls = new IMulticall3.Call3[](2); + t.preCalls[0] = IMulticall3.Call3({ + target: address(gasBurner), + allowFailure: false, + callData: abi.encodeWithSignature("setRandomness(uint256)", 42) + }); + t.preCalls[1] = IMulticall3.Call3({ + target: address(paymentToken), + allowFailure: false, + callData: abi.encodeWithSignature("approve(address,uint256)", address(this), 1000) + }); + + Orchestrator.Intent memory i; + i.eoa = d.eoa; + i.nonce = 0; + i.executionData = t.executionData; + i.payer = address(0x00); + i.paymentToken = address(paymentToken); + i.paymentRecipient = address(0x00); + i.paymentAmount = 0x112233112233112233112233; + i.paymentMaxAmount = 0x445566445566445566445566; + i.combinedGas = 20_000; + + { + // Just pass in a junk secp256k1 signature. + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(uint128(_randomUniform()), bytes32(_randomUniform())); + i.signature = abi.encodePacked(r, s, v); + } + + // If the caller does not have max balance, then the simulation should revert. + vm.expectRevert(bytes4(keccak256("StateOverrideError()"))); + (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + address(multicall3), t.preCalls, address(oc), 0, 1, 11_000, 10_000, abi.encode(i) + ); + + uint256 snapshot = vm.snapshotState(); + vm.deal(_ORIGIN_ADDRESS, type(uint192).max); + + (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, 0, abi.encode(i) + ); + + vm.revertToStateAndDelete(snapshot); + i.combinedGas = t.gCombined; + + t.gExecute = t.gCombined + 10_000; + + i.signature = _sig(d, i); + + vm.expectRevert(bytes4(keccak256("InsufficientGas()"))); + oc.execute{gas: t.gExecute}(abi.encode(i)); + + t.gExecute = Math.mulDiv(t.gCombined + 110_000, 64, 63); + + assertEq(oc.execute{gas: t.gExecute}(abi.encode(i)), 0); + } + + function testSimulateMulticall3NoRevertUnderfundedReverts() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + assertEq(_balanceOf(address(paymentToken), d.eoa), 0); + + _SimulateExecuteTemps memory t; + + gasBurner.setRandomness(1); // Warm the storage first. + + t.gasToBurn = _gasToBurn(); + do { + t.randomness = _randomUniform(); + } while (t.randomness == 0); + emit LogUint("gasToBurn", t.gasToBurn); + + t.executionData = _executionData( + address(gasBurner), + abi.encodeWithSignature("burnGas(uint256,uint256)", t.gasToBurn, t.randomness) + ); + + // Empty preCalls for this test + t.preCalls = new IMulticall3.Call3[](0); + + Orchestrator.Intent memory i; + i.eoa = d.eoa; + i.nonce = 0; + i.executionData = t.executionData; + i.payer = address(0x00); + i.paymentToken = address(paymentToken); + i.paymentRecipient = address(0x00); + i.paymentAmount = 0x112233112233112233112233; + i.paymentMaxAmount = 0x445566445566445566445566; + i.combinedGas = 20_000; + + { + // Just pass in a junk secp256k1 signature. + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(uint128(_randomUniform()), bytes32(_randomUniform())); + i.signature = abi.encodePacked(r, s, v); + } + + vm.expectRevert(bytes4(keccak256("PaymentError()"))); + simulator.simulateMulticall3V1Logs( + address(multicall3), t.preCalls, address(oc), 0, 1, 11_000, 0, abi.encode(i) + ); + + deal(i.paymentToken, address(i.eoa), 0x112233112233112233112233); + vm.expectRevert(bytes4(keccak256("PaymentError()"))); + simulator.simulateMulticall3CombinedGas( + address(multicall3), t.preCalls, address(oc), 0, 1, 11_000, abi.encode(i) + ); + } + + function testSimulateMulticall3WithEOAKey(bytes32) public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + + paymentToken.mint(d.eoa, 500 ether); + + _SimulateExecuteTemps memory t; + + gasBurner.setRandomness(1); // Warm the storage first. + + t.gasToBurn = _gasToBurn(); + do { + t.randomness = _randomUniform(); + } while (t.randomness == 0); + emit LogUint("gasToBurn", t.gasToBurn); + + t.executionData = _executionData( + address(gasBurner), + abi.encodeWithSignature("burnGas(uint256,uint256)", t.gasToBurn, t.randomness) + ); + + // Create a single preCall + t.preCalls = new IMulticall3.Call3[](1); + t.preCalls[0] = IMulticall3.Call3({ + target: address(gasBurner), + allowFailure: false, + callData: abi.encodeWithSignature("setRandomness(uint256)", 1) + }); + + Orchestrator.Intent memory i; + i.eoa = d.eoa; + i.nonce = 0; + i.executionData = t.executionData; + i.payer = address(0x00); + i.paymentToken = address(paymentToken); + i.paymentRecipient = address(0x00); + i.paymentAmount = _randomChance(2) ? 0 : 0.1 ether; + i.paymentMaxAmount = _bound(_random(), i.paymentAmount, 0.5 ether); + i.combinedGas = 20_000; + + { + // Just pass in a junk secp256k1 signature. + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(uint128(_randomUniform()), bytes32(_randomUniform())); + i.signature = abi.encodePacked(r, s, v); + } + + uint256 snapshot = vm.snapshotState(); + vm.deal(_ORIGIN_ADDRESS, type(uint192).max); + + (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + address(multicall3), t.preCalls, address(oc), 2, 1e11, 10_800, 0, abi.encode(i) + ); + + vm.revertToStateAndDelete(snapshot); + + i.combinedGas = t.gCombined; + // gExecute > (100k + combinedGas) * 64/63 + t.gExecute = Math.mulDiv(t.gCombined + 110_000, 64, 63); + + i.signature = _sig(d, i); + + assertEq(oc.execute{gas: t.gExecute}(abi.encode(i)), 0); + assertEq(gasBurner.randomness(), t.randomness); + } + + function testSimulateMulticall3WithPassKey(bytes32) public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + + vm.deal(d.eoa, 10 ether); + paymentToken.mint(d.eoa, 50 ether); + + PassKey memory k = _randomPassKey(); // Can be r1 or k1. + k.k.isSuperAdmin = true; + + vm.prank(d.eoa); + d.d.authorize(k.k); + + _SimulateExecuteTemps memory t; + + t.gasToBurn = _gasToBurn(); + do { + t.randomness = _randomUniform(); + } while (t.randomness == 0); + emit LogUint("gasToBurn", t.gasToBurn); + t.executionData = _executionData( + address(gasBurner), + abi.encodeWithSignature("burnGas(uint256,uint256)", t.gasToBurn, t.randomness) + ); + + // Create preCalls with token approval + t.preCalls = new IMulticall3.Call3[](1); + t.preCalls[0] = IMulticall3.Call3({ + target: address(paymentToken), + allowFailure: false, + callData: abi.encodeWithSignature( + "approve(address,uint256)", address(oc), type(uint256).max + ) + }); + + Orchestrator.Intent memory i; + i.eoa = d.eoa; + i.nonce = 0; + i.executionData = t.executionData; + i.payer = address(0x00); + i.paymentToken = address(paymentToken); + i.paymentRecipient = address(0x00); + i.paymentAmount = _randomChance(2) ? 0 : 0.1 ether; + i.paymentMaxAmount = _bound(_random(), i.paymentAmount, 0.5 ether); + i.combinedGas = 20_000; + + // Just fill with some non-zero junk P256 signature that contains the `keyHash`, + // so that the `simulateExecute` knows that + // it needs to add the variance for non-precompile P256 verification. + // We need the `keyHash` in the signature so that the simulation is able + // to hit all the gas for the GuardedExecutor stuff for the `keyHash`. + i.signature = abi.encodePacked(keccak256("a"), keccak256("b"), k.keyHash, uint8(0)); + + uint256 snapshot = vm.snapshotState(); + vm.deal(_ORIGIN_ADDRESS, type(uint192).max); + + (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + address(multicall3), t.preCalls, address(oc), 2, 1e11, 12_000, 10_000, abi.encode(i) + ); + + vm.revertToStateAndDelete(snapshot); + + i.combinedGas = t.gCombined; + t.gExecute = Math.mulDiv(t.gCombined + 110_000, 64, 63); + + i.signature = _sig(k, i); + + assertEq(oc.execute{gas: t.gExecute}(abi.encode(i)), 0); + assertEq(gasBurner.randomness(), t.randomness); + } + + function testSimulateMulticall3EmptyPreCalls() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + + paymentToken.mint(d.eoa, type(uint128).max); + + _SimulateExecuteTemps memory t; + + gasBurner.setRandomness(1); // Warm the storage first. + + t.gasToBurn = _gasToBurn(); + do { + t.randomness = _randomUniform(); + } while (t.randomness == 0); + + t.executionData = _executionData( + address(gasBurner), + abi.encodeWithSignature("burnGas(uint256,uint256)", t.gasToBurn, t.randomness) + ); + + // Empty preCalls - should behave similarly to regular simulate + t.preCalls = new IMulticall3.Call3[](0); + + Orchestrator.Intent memory i; + i.eoa = d.eoa; + i.nonce = 0; + i.executionData = t.executionData; + i.payer = address(0x00); + i.paymentToken = address(paymentToken); + i.paymentRecipient = address(0x00); + i.paymentAmount = 0x112233112233112233112233; + i.paymentMaxAmount = 0x445566445566445566445566; + i.combinedGas = 100_000; + + { + // Just pass in a junk secp256k1 signature. + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(uint128(_randomUniform()), bytes32(_randomUniform())); + i.signature = abi.encodePacked(r, s, v); + } + + uint256 snapshot = vm.snapshotState(); + vm.deal(_ORIGIN_ADDRESS, type(uint192).max); + + (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, 0, abi.encode(i) + ); + + vm.revertToStateAndDelete(snapshot); + + i.combinedGas = t.gCombined; + t.gExecute = Math.mulDiv(t.gCombined + 110_000, 64, 63); + + i.signature = _sig(d, i); + + assertEq(oc.execute{gas: t.gExecute}(abi.encode(i)), 0); + assertEq(gasBurner.randomness(), t.randomness); + } + + function testSimulateMulticall3MultiplePreCalls() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + + paymentToken.mint(d.eoa, type(uint128).max); + + _SimulateExecuteTemps memory t; + + gasBurner.setRandomness(1); // Warm the storage first. + + t.gasToBurn = _gasToBurn(); + do { + t.randomness = _randomUniform(); + } while (t.randomness == 0); + + t.executionData = _executionData( + address(gasBurner), + abi.encodeWithSignature("burnGas(uint256,uint256)", t.gasToBurn, t.randomness) + ); + + // Multiple preCalls with different operations + t.preCalls = new IMulticall3.Call3[](3); + t.preCalls[0] = IMulticall3.Call3({ + target: address(gasBurner), + allowFailure: false, + callData: abi.encodeWithSignature("setRandomness(uint256)", 100) + }); + t.preCalls[1] = IMulticall3.Call3({ + target: address(paymentToken), + allowFailure: false, + callData: abi.encodeWithSignature("approve(address,uint256)", address(this), 5000) + }); + t.preCalls[2] = IMulticall3.Call3({ + target: address(gasBurner), + allowFailure: false, + callData: abi.encodeWithSignature("setRandomness(uint256)", 1) + }); + + Orchestrator.Intent memory i; + i.eoa = d.eoa; + i.nonce = 0; + i.executionData = t.executionData; + i.payer = address(0x00); + i.paymentToken = address(paymentToken); + i.paymentRecipient = address(0x00); + i.paymentAmount = 0.1 ether; + i.paymentMaxAmount = 0.5 ether; + i.combinedGas = 20_000; + + { + // Just pass in a junk secp256k1 signature. + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(uint128(_randomUniform()), bytes32(_randomUniform())); + i.signature = abi.encodePacked(r, s, v); + } + + uint256 snapshot = vm.snapshotState(); + vm.deal(_ORIGIN_ADDRESS, type(uint192).max); + + (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, 0, abi.encode(i) + ); + + vm.revertToStateAndDelete(snapshot); + + i.combinedGas = t.gCombined; + t.gExecute = Math.mulDiv(t.gCombined + 110_000, 64, 63); + + i.signature = _sig(d, i); + + assertEq(oc.execute{gas: t.gExecute}(abi.encode(i)), 0); + assertEq(gasBurner.randomness(), t.randomness); + } +} + +// Mock Multicall3 implementation for testing +contract MockMulticall3 is IMulticall3 { + function aggregate(Call[] calldata calls) + external + payable + override + returns (uint256 blockNumber, bytes[] memory returnData) + { + blockNumber = block.number; + returnData = new bytes[](calls.length); + for (uint256 i = 0; i < calls.length; i++) { + (bool success, bytes memory ret) = calls[i].target.call(calls[i].callData); + if (!success) revert("Multicall3: call failed"); + returnData[i] = ret; + } + } + + function aggregate3(Call3[] calldata calls) + external + payable + override + returns (Result[] memory returnData) + { + returnData = new Result[](calls.length); + for (uint256 i = 0; i < calls.length; i++) { + (bool success, bytes memory ret) = calls[i].target.call(calls[i].callData); + returnData[i] = Result({success: success, returnData: ret}); + if (!calls[i].allowFailure && !success) { + revert("Multicall3: call failed"); + } + } + } } From c378c3bfcd99e2762b91fbfa7b64e76f9ca4f584 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:55:46 -0400 Subject: [PATCH 2/4] chore: add gas test --- snapshots/BenchmarkTest.json | 102 +++++++++++++++++------------------ src/Simulator.sol | 7 ++- test/SimulateExecute.t.sol | 69 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 53 deletions(-) diff --git a/snapshots/BenchmarkTest.json b/snapshots/BenchmarkTest.json index dedb663c..82f7d2ae 100644 --- a/snapshots/BenchmarkTest.json +++ b/snapshots/BenchmarkTest.json @@ -1,53 +1,53 @@ { - "testERC20Transfer_AlchemyModularAccount": "159052", - "testERC20Transfer_AlchemyModularAccount_AppSponsor": "134278", - "testERC20Transfer_AlchemyModularAccount_ERC20SelfPay": "160169", - "testERC20Transfer_Batch100_AlchemyModularAccount_AppSponsor": "7053863", - "testERC20Transfer_Batch100_CoinbaseSmartWallet": "9801043", - "testERC20Transfer_Batch100_CoinbaseSmartWallet_AppSponsor": "6483523", - "testERC20Transfer_Batch100_CoinbaseSmartWallet_ERC20SelfPay": "8487729", - "testERC20Transfer_Batch100_Safe4337": "11165400", - "testERC20Transfer_Batch100_Safe4337_AppSponsor": "7525827", - "testERC20Transfer_Batch100_Safe4337_ERC20SelfPay": "9540939", - "testERC20Transfer_Batch100_ZerodevKernel_AppSponsor": "8956539", - "testERC20Transfer_CoinbaseSmartWallet": "150133", - "testERC20Transfer_CoinbaseSmartWallet_AppSponsor": "126009", - "testERC20Transfer_CoinbaseSmartWallet_ERC20SelfPay": "150241", - "testERC20Transfer_ERC4337MinimalAccount": "148602", - "testERC20Transfer_ERC4337MinimalAccount_AppSponsor": "123885", - "testERC20Transfer_ERC4337MinimalAccount_ERC20SelfPay": "149776", - "testERC20Transfer_IthacaAccount": "91666", - "testERC20Transfer_IthacaAccountWithSpendLimits": "113167", - "testERC20Transfer_IthacaAccount_AppSponsor": "99218", - "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "104319", - "testERC20Transfer_IthacaAccount_ERC20SelfPay": "91967", - "testERC20Transfer_Safe4337": "163675", - "testERC20Transfer_Safe4337_AppSponsor": "136323", - "testERC20Transfer_Safe4337_ERC20SelfPay": "160610", - "testERC20Transfer_ZerodevKernel": "175447", - "testERC20Transfer_ZerodevKernel_AppSponsor": "150734", - "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "176663", - "testERC20Transfer_batch100_AlchemyModularAccount": "10438358", - "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "9221814", - "testERC20Transfer_batch100_IthacaAccount": "6201928", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "6758228", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "6565428", - "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "6004328", - "testERC20Transfer_batch100_ZerodevKernel": "12332690", - "testERC20Transfer_batch100_ZerodevKernel_ERC20SelfPay": "11134785", - "testNativeTransfer_AlchemyModularAccount": "168453", - "testNativeTransfer_CoinbaseSmartWallet": "159248", - "testNativeTransfer_IthacaAccount": "101064", - "testNativeTransfer_IthacaAccount_AppSponsor": "108635", - "testNativeTransfer_IthacaAccount_ERC20SelfPay": "101365", - "testNativeTransfer_Safe4337": "172763", - "testNativeTransfer_ZerodevKernel": "184855", - "testUniswapV2Swap_AlchemyModularAccount": "210111", - "testUniswapV2Swap_CoinbaseSmartWallet": "201623", - "testUniswapV2Swap_ERC4337MinimalAccount": "199690", - "testUniswapV2Swap_IthacaAccount": "142728", - "testUniswapV2Swap_IthacaAccount_AppSponsor": "150244", - "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "143029", - "testUniswapV2Swap_Safe4337": "215353", - "testUniswapV2Swap_ZerodevKernel": "226591" + "testERC20Transfer_AlchemyModularAccount": "179494", + "testERC20Transfer_AlchemyModularAccount_AppSponsor": "176436", + "testERC20Transfer_AlchemyModularAccount_ERC20SelfPay": "207779", + "testERC20Transfer_Batch100_AlchemyModularAccount_AppSponsor": "8897167", + "testERC20Transfer_Batch100_CoinbaseSmartWallet": "9952203", + "testERC20Transfer_Batch100_CoinbaseSmartWallet_AppSponsor": "8787327", + "testERC20Transfer_Batch100_CoinbaseSmartWallet_ERC20SelfPay": "11335605", + "testERC20Transfer_Batch100_Safe4337": "11685484", + "testERC20Transfer_Batch100_Safe4337_AppSponsor": "10198375", + "testERC20Transfer_Batch100_Safe4337_ERC20SelfPay": "12757523", + "testERC20Transfer_Batch100_ZerodevKernel_AppSponsor": "11427583", + "testERC20Transfer_CoinbaseSmartWallet": "177855", + "testERC20Transfer_CoinbaseSmartWallet_AppSponsor": "175259", + "testERC20Transfer_CoinbaseSmartWallet_ERC20SelfPay": "204919", + "testERC20Transfer_ERC4337MinimalAccount": "171509", + "testERC20Transfer_ERC4337MinimalAccount_AppSponsor": "168500", + "testERC20Transfer_ERC4337MinimalAccount_ERC20SelfPay": "199831", + "testERC20Transfer_IthacaAccount": "128195", + "testERC20Transfer_IthacaAccountWithSpendLimits": "193658", + "testERC20Transfer_IthacaAccount_AppSponsor": "138709", + "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "144010", + "testERC20Transfer_IthacaAccount_ERC20SelfPay": "128684", + "testERC20Transfer_Safe4337": "197561", + "testERC20Transfer_Safe4337_AppSponsor": "191725", + "testERC20Transfer_Safe4337_ERC20SelfPay": "221464", + "testERC20Transfer_ZerodevKernel": "207117", + "testERC20Transfer_ZerodevKernel_AppSponsor": "204120", + "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "235489", + "testERC20Transfer_batch100_AlchemyModularAccount": "10109066", + "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "11609298", + "testERC20Transfer_batch100_IthacaAccount": "7545392", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "8151496", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "7977508", + "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "7366652", + "testERC20Transfer_batch100_ZerodevKernel": "12631318", + "testERC20Transfer_batch100_ZerodevKernel_ERC20SelfPay": "14149937", + "testNativeTransfer_AlchemyModularAccount": "180829", + "testNativeTransfer_CoinbaseSmartWallet": "178916", + "testNativeTransfer_IthacaAccount": "129551", + "testNativeTransfer_IthacaAccount_AppSponsor": "140096", + "testNativeTransfer_IthacaAccount_ERC20SelfPay": "137340", + "testNativeTransfer_Safe4337": "198595", + "testNativeTransfer_ZerodevKernel": "208635", + "testUniswapV2Swap_AlchemyModularAccount": "238647", + "testUniswapV2Swap_CoinbaseSmartWallet": "237451", + "testUniswapV2Swap_ERC4337MinimalAccount": "230691", + "testUniswapV2Swap_IthacaAccount": "187339", + "testUniswapV2Swap_IthacaAccount_AppSponsor": "197817", + "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "192628", + "testUniswapV2Swap_Safe4337": "257333", + "testUniswapV2Swap_ZerodevKernel": "266367" } \ No newline at end of file diff --git a/src/Simulator.sol b/src/Simulator.sol index f195564c..e6e872f7 100644 --- a/src/Simulator.sol +++ b/src/Simulator.sol @@ -165,8 +165,9 @@ contract Simulator { IMulticall3.Result[] memory results = IMulticall3(multicall3).aggregate3(calls); // Get the last result (orchestrator call result) + // Check all preCalls for failures (all results except the last one, which is the orchestrator call) if (results.length > 1) { - for (uint256 i = 0; i < results.length - 2; i++) { + for (uint256 i = 0; i < results.length - 1; i++) { // If any pre-call failed, we return gasUsed = 0 and the error data from that call if (!results[i].success) { return (0, results[i].returnData); @@ -409,11 +410,13 @@ contract Simulator { // Layout: [length (32 bytes)][selector (4 bytes)][additional data...] bytes4 errorSelector; assembly ("memory-safe") { - // Load 32 bytes starting from errorData+32, then shift right to get first 4 bytes + // Load 32 bytes starting from errorData+32 + // bytes4 values are already right-aligned, no shift needed errorSelector := mload(add(errorData, 32)) } if (errorSelector == 0xabab8fc9) { // PaymentError() + // Revert with just the selector (0x20 bytes = 4 bytes selector + 28 bytes padding) assembly ("memory-safe") { mstore(0x00, errorSelector) diff --git a/test/SimulateExecute.t.sol b/test/SimulateExecute.t.sol index 981e2deb..5f725747 100644 --- a/test/SimulateExecute.t.sol +++ b/test/SimulateExecute.t.sol @@ -720,6 +720,75 @@ contract SimulateExecuteTest is BaseTest { assertEq(oc.execute{gas: t.gExecute}(abi.encode(i)), 0); assertEq(gasBurner.randomness(), t.randomness); } + + function testSimulateVsActualGas() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + paymentToken.mint(d.eoa, type(uint128).max); + + _SimulateExecuteTemps memory t; + + gasBurner.setRandomness(1); + + t.gasToBurn = _gasToBurn(); + do { + t.randomness = _randomUniform(); + } while (t.randomness == 0); + + t.executionData = _executionData( + address(gasBurner), + abi.encodeWithSignature("burnGas(uint256,uint256)", t.gasToBurn, t.randomness) + ); + + Orchestrator.Intent memory i; + i.eoa = d.eoa; + i.nonce = 0; + i.executionData = t.executionData; + i.payer = address(0x00); + i.paymentToken = address(paymentToken); + i.paymentRecipient = address(0x00); + i.paymentAmount = 0.1 ether; + i.paymentMaxAmount = 0.5 ether; + i.combinedGas = 20_000; + + { + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(uint128(_randomUniform()), bytes32(_randomUniform())); + i.signature = abi.encodePacked(r, s, v); + } + + vm.deal(_ORIGIN_ADDRESS, type(uint192).max); + + // Get simulated gas using multicall3 with empty preCalls (simulation automatically reverts, no state changes persist) + t.preCalls = new IMulticall3.Call3[](0); + (uint256 simulatedGas, uint256 combinedGas) = simulator.simulateMulticall3CombinedGas( + address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, abi.encode(i) + ); + + // Now execute through multicall3 with the calculated combinedGas and measure actual gas + i.combinedGas = combinedGas; + i.signature = _sig(d, i); + t.gExecute = Math.mulDiv(combinedGas + 110_000, 64, 63); + + // Build multicall3 calls array: empty preCalls + orchestrator execute call + IMulticall3.Call3[] memory executeCalls = new IMulticall3.Call3[](1); + executeCalls[0] = IMulticall3.Call3({ + target: address(oc), + allowFailure: false, + callData: abi.encodeWithSignature("execute(bytes)", abi.encode(i)) + }); + + IMulticall3.Result[] memory results = multicall3.aggregate3{gas: t.gExecute}(executeCalls); + + // Check that the orchestrator call succeeded and returned 0 (no error) + if (results.length > 0 && results[0].success) { + bytes4 err = abi.decode(results[0].returnData, (bytes4)); + assertEq(err, 0, "Execution should succeed"); + } else { + revert("Multicall3 execution failed"); + } + + assertEq(gasBurner.randomness(), t.randomness); + } } // Mock Multicall3 implementation for testing From 161bd4cb22fb8a5b710d5b4fdd6fe7bae9b42d65 Mon Sep 17 00:00:00 2001 From: howy <132113803+howydev@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:34:40 -0400 Subject: [PATCH 3/4] chore: review fixes --- src/Simulator.sol | 68 ++++++++++++++++++++++---------------- test/SimulateExecute.t.sol | 15 +++++---- 2 files changed, 48 insertions(+), 35 deletions(-) diff --git a/src/Simulator.sol b/src/Simulator.sol index e6e872f7..786b9eca 100644 --- a/src/Simulator.sol +++ b/src/Simulator.sol @@ -126,24 +126,31 @@ contract Simulator { return _callOrchestrator(oc, isStateOverride, data); } - /// @dev Performs a call to Multicall3 with pre-calls followed by an orchestrator call. + /// @dev Performs a call to Multicall3 with calls followed by an orchestrator call. /// Returns the gas used parsed from the last Result in the multicall3 response. /// If parsing fails (gasUsed == 0), this function stores the orchestrator's error in memory /// so the caller can bubble it up using bubbleUpMulticall3Error. /// @param multicall3 The multicall3 contract address - /// @param preCalls Array of Call3 structs representing calls to execute before the orchestrator call + /// @param calls Array of Call3 structs to execute before the orchestrator call /// @param oc The orchestrator address /// @param isStateOverride Whether to use state override mode for the orchestrator call /// @param combinedGasOverride The combined gas override value /// @param u The Intent struct to pass to the orchestrator + /// @return gasUsed The gas used by the orchestrator call (parsed from SimulationPassed or state override result) + /// @return multicall3Gas The gas spent on the aggregate3 call itself + /// @return lastReturnData The return data from the orchestrator call function _callMulticall3( address multicall3, - IMulticall3.Call3[] memory preCalls, + IMulticall3.Call3[] memory calls, address oc, bool isStateOverride, uint256 combinedGasOverride, ICommon.Intent memory u - ) internal freeTempMemory returns (uint256 gasUsed, bytes memory lastReturnData) { + ) + internal + freeTempMemory + returns (uint256 gasUsed, uint256 multicall3Gas, bytes memory lastReturnData) + { // Build the orchestrator call data bytes memory orchestratorData = abi.encodeWithSignature( "simulateExecute(bool,uint256,bytes)", @@ -152,25 +159,27 @@ contract Simulator { abi.encode(u) ); - // Construct the full Call3[] array: preCalls + orchestrator call - IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](preCalls.length + 1); - for (uint256 i = 0; i < preCalls.length; i++) { - calls[i] = preCalls[i]; + // Construct the full Call3[] array: calls + orchestrator call + IMulticall3.Call3[] memory allCalls = new IMulticall3.Call3[](calls.length + 1); + for (uint256 i = 0; i < calls.length; i++) { + allCalls[i] = calls[i]; } // Last call is to the orchestrator - calls[preCalls.length] = + allCalls[calls.length] = IMulticall3.Call3({target: oc, allowFailure: true, callData: orchestratorData}); - // Call multicall3.aggregate3 - IMulticall3.Result[] memory results = IMulticall3(multicall3).aggregate3(calls); + // Measure gas before and after aggregate3 call + uint256 gasBefore = gasleft(); + IMulticall3.Result[] memory results = IMulticall3(multicall3).aggregate3(allCalls); + multicall3Gas = gasBefore - gasleft(); // Get the last result (orchestrator call result) - // Check all preCalls for failures (all results except the last one, which is the orchestrator call) + // Check all calls for failures (all results except the last one, which is the orchestrator call) if (results.length > 1) { for (uint256 i = 0; i < results.length - 1; i++) { - // If any pre-call failed, we return gasUsed = 0 and the error data from that call + // If any call failed, we return gasUsed = 0, multicall3Gas, and the error data from that call if (!results[i].success) { - return (0, results[i].returnData); + return (0, 0, results[i].returnData); } } } @@ -348,12 +357,12 @@ contract Simulator { } } - /// @dev Simulates the execution of an intent through Multicall3, with pre-calls executed before the orchestrator call. + /// @dev Simulates the execution of an intent through Multicall3, with calls executed before the orchestrator call. /// Finds the combined gas by iteratively increasing it until the simulation passes. /// The start value for combinedGas is gasUsed + original combinedGas. /// Set u.combinedGas to add some starting offset to the gasUsed value. /// @param multicall3 The multicall3 contract address - /// @param preCalls Array of Call3 structs representing calls to execute before the orchestrator call + /// @param calls Array of Call3 structs to execute before the orchestrator call /// @param oc The orchestrator address /// @param paymentPerGasPrecision The precision of the payment per gas value. /// paymentAmount = gas * paymentPerGas / (10 ** paymentPerGasPrecision) @@ -366,19 +375,20 @@ contract Simulator { /// If the increment is too small, the function might run out of gas while finding the combined gas value. /// @param encodedIntent The encoded user operation /// @return gasUsed The gas used in the successful simulation + /// @return multicall3Gas The gas spent on the aggregate3 call /// @return combinedGas The first combined gas value that gives a successful simulation. /// This function reverts if the primary simulation run with max combinedGas fails. /// If the primary run is successful, it iteratively increases u.combinedGas by `combinedGasIncrement` until the simulation passes. /// All failing simulations during this run are ignored. function simulateMulticall3CombinedGas( address multicall3, - IMulticall3.Call3[] memory preCalls, + IMulticall3.Call3[] memory calls, address oc, uint8 paymentPerGasPrecision, uint256 paymentPerGas, uint256 combinedGasIncrement, bytes calldata encodedIntent - ) public payable virtual returns (uint256 gasUsed, uint256 combinedGas) { + ) public payable virtual returns (uint256 gasUsed, uint256 multicall3Gas, uint256 combinedGas) { // Decode the intent first ICommon.Intent memory u = abi.decode(encodedIntent, (ICommon.Intent)); @@ -386,8 +396,8 @@ contract Simulator { // 1. Primary Simulation Run to get initial gasUsed value with combinedGasOverride - (gasUsed, errorData) = - _callMulticall3(multicall3, preCalls, oc, false, type(uint256).max, u); + (gasUsed, multicall3Gas, errorData) = + _callMulticall3(multicall3, calls, oc, false, type(uint256).max, u); // If the simulation failed, bubble up the orchestrator's error. if (gasUsed == 0) { @@ -399,7 +409,8 @@ contract Simulator { _updatePaymentAmounts(u, u.combinedGas, paymentPerGasPrecision, paymentPerGas); while (true) { - (gasUsed, errorData) = _callMulticall3(multicall3, preCalls, oc, false, 0, u); + (gasUsed, multicall3Gas, errorData) = + _callMulticall3(multicall3, calls, oc, false, 0, u); // If the simulation failed, check if it's a PaymentError and bubble it up. // PaymentError is given special treatment here, as it comes from @@ -426,7 +437,7 @@ contract Simulator { } if (gasUsed != 0) { - return (gasUsed, u.combinedGas); + return (gasUsed, multicall3Gas, u.combinedGas); } uint256 gasIncrement = Math.mulDiv(u.combinedGas, combinedGasIncrement, 10_000); @@ -442,7 +453,7 @@ contract Simulator { /// that generates a successful non reverting state override simulation. /// Which can be used in eth_simulateV1 to get the trace. /// @param multicall3 The multicall3 contract address - /// @param preCalls Array of Call3 structs representing calls to execute before the orchestrator call + /// @param calls Array of Call3 structs to execute before the orchestrator call /// @param oc The orchestrator address /// @param paymentPerGasPrecision The precision of the payment per gas value. /// paymentAmount = gas * paymentPerGas / (10 ** paymentPerGasPrecision) @@ -452,20 +463,21 @@ contract Simulator { /// This can be used to account for variations in sig verification gas, for keytypes like P256. /// @param encodedIntent The encoded user operation /// @return gasUsed The gas used in the successful simulation + /// @return multicall3Gas The gas spent on the aggregate3 call /// @return combinedGas The combined gas value including the verification offset function simulateMulticall3V1Logs( address multicall3, - IMulticall3.Call3[] memory preCalls, + IMulticall3.Call3[] memory calls, address oc, uint8 paymentPerGasPrecision, uint256 paymentPerGas, uint256 combinedGasIncrement, uint256 combinedGasVerificationOffset, bytes calldata encodedIntent - ) public payable virtual returns (uint256 gasUsed, uint256 combinedGas) { - (gasUsed, combinedGas) = simulateMulticall3CombinedGas( + ) public payable virtual returns (uint256 gasUsed, uint256 multicall3Gas, uint256 combinedGas) { + (gasUsed, multicall3Gas, combinedGas) = simulateMulticall3CombinedGas( multicall3, - preCalls, + calls, oc, paymentPerGasPrecision, paymentPerGas, @@ -484,7 +496,7 @@ contract Simulator { bytes memory errorData; // Verification Run to generate the logs with the correct combinedGas and payment amounts. - (gasUsed, errorData) = _callMulticall3(multicall3, preCalls, oc, true, 0, u); + (gasUsed, multicall3Gas, errorData) = _callMulticall3(multicall3, calls, oc, true, 0, u); // If the simulation failed, bubble up the orchestrator's error if (gasUsed == 0) { diff --git a/test/SimulateExecute.t.sol b/test/SimulateExecute.t.sol index 5f725747..099d6acc 100644 --- a/test/SimulateExecute.t.sol +++ b/test/SimulateExecute.t.sol @@ -22,6 +22,7 @@ contract SimulateExecuteTest is BaseTest { uint256 gExecute; uint256 gCombined; uint256 gUsed; + uint256 gMulticall3; bytes executionData; bool success; bytes result; @@ -377,14 +378,14 @@ contract SimulateExecuteTest is BaseTest { // If the caller does not have max balance, then the simulation should revert. vm.expectRevert(bytes4(keccak256("StateOverrideError()"))); - (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + (t.gUsed, t.gMulticall3, t.gCombined) = simulator.simulateMulticall3V1Logs( address(multicall3), t.preCalls, address(oc), 0, 1, 11_000, 10_000, abi.encode(i) ); uint256 snapshot = vm.snapshotState(); vm.deal(_ORIGIN_ADDRESS, type(uint192).max); - (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + (t.gUsed, t.gMulticall3, t.gCombined) = simulator.simulateMulticall3V1Logs( address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, 0, abi.encode(i) ); @@ -504,7 +505,7 @@ contract SimulateExecuteTest is BaseTest { uint256 snapshot = vm.snapshotState(); vm.deal(_ORIGIN_ADDRESS, type(uint192).max); - (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + (t.gUsed, t.gMulticall3, t.gCombined) = simulator.simulateMulticall3V1Logs( address(multicall3), t.preCalls, address(oc), 2, 1e11, 10_800, 0, abi.encode(i) ); @@ -575,7 +576,7 @@ contract SimulateExecuteTest is BaseTest { uint256 snapshot = vm.snapshotState(); vm.deal(_ORIGIN_ADDRESS, type(uint192).max); - (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + (t.gUsed, t.gMulticall3, t.gCombined) = simulator.simulateMulticall3V1Logs( address(multicall3), t.preCalls, address(oc), 2, 1e11, 12_000, 10_000, abi.encode(i) ); @@ -633,7 +634,7 @@ contract SimulateExecuteTest is BaseTest { uint256 snapshot = vm.snapshotState(); vm.deal(_ORIGIN_ADDRESS, type(uint192).max); - (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + (t.gUsed, t.gMulticall3, t.gCombined) = simulator.simulateMulticall3V1Logs( address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, 0, abi.encode(i) ); @@ -706,7 +707,7 @@ contract SimulateExecuteTest is BaseTest { uint256 snapshot = vm.snapshotState(); vm.deal(_ORIGIN_ADDRESS, type(uint192).max); - (t.gUsed, t.gCombined) = simulator.simulateMulticall3V1Logs( + (t.gUsed, t.gMulticall3, t.gCombined) = simulator.simulateMulticall3V1Logs( address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, 0, abi.encode(i) ); @@ -760,7 +761,7 @@ contract SimulateExecuteTest is BaseTest { // Get simulated gas using multicall3 with empty preCalls (simulation automatically reverts, no state changes persist) t.preCalls = new IMulticall3.Call3[](0); - (uint256 simulatedGas, uint256 combinedGas) = simulator.simulateMulticall3CombinedGas( + (uint256 simulatedGas, uint256 multicall3Gas, uint256 combinedGas) = simulator.simulateMulticall3CombinedGas( address(multicall3), t.preCalls, address(oc), 2, 1e11, 11_000, abi.encode(i) ); From f21b20ecd6ae1f0d82810b0d6186a9fa4edcb36e Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:38:43 +0000 Subject: [PATCH 4/4] chore: fmt contracts and update gas snapshots --- src/LayerZeroSettler.sol | 10 +++++++++- src/Simulator.sol | 4 ++-- test/Benchmark.t.sol | 13 +++++++++---- test/GuardedExecutor.t.sol | 8 ++++++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/LayerZeroSettler.sol b/src/LayerZeroSettler.sol index aa8d76a9..276b50ce 100644 --- a/src/LayerZeroSettler.sol +++ b/src/LayerZeroSettler.sol @@ -102,7 +102,15 @@ contract LayerZeroSettler is OApp, ISettler, EIP712 { } } - function _getPeerOrRevert(uint32 /* _eid */) internal view virtual override returns (bytes32) { + function _getPeerOrRevert( + uint32 /* _eid */ + ) + internal + view + virtual + override + returns (bytes32) + { // The peer address for all chains is automatically set to `address(this)` return bytes32(uint256(uint160(address(this)))); } diff --git a/src/Simulator.sol b/src/Simulator.sol index 786b9eca..1e468d6b 100644 --- a/src/Simulator.sol +++ b/src/Simulator.sol @@ -425,8 +425,8 @@ contract Simulator { // bytes4 values are already right-aligned, no shift needed errorSelector := mload(add(errorData, 32)) } - if (errorSelector == 0xabab8fc9) { // PaymentError() - + if (errorSelector == 0xabab8fc9) { + // PaymentError() // Revert with just the selector (0x20 bytes = 4 bytes selector + 28 bytes padding) assembly ("memory-safe") { diff --git a/test/Benchmark.t.sol b/test/Benchmark.t.sol index 6b1b5c49..2ce04524 100644 --- a/test/Benchmark.t.sol +++ b/test/Benchmark.t.sol @@ -1772,11 +1772,16 @@ contract BenchmarkTest is BaseTest { vm.startPrank(d.eoa); d.d.authorize(k.k); d.d - .setCanExecute( - k.keyHash, address(paymentToken), bytes4(keccak256("transfer(address,uint256)")), true - ); + .setCanExecute( + k.keyHash, + address(paymentToken), + bytes4(keccak256("transfer(address,uint256)")), + true + ); d.d - .setSpendLimit(k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Hour, 1 ether); + .setSpendLimit( + k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Hour, 1 ether + ); d.d.setSpendLimit(k.keyHash, address(0), GuardedExecutor.SpendPeriod.Hour, 1 ether); vm.stopPrank(); diff --git a/test/GuardedExecutor.t.sol b/test/GuardedExecutor.t.sol index 088bd597..a30c941e 100644 --- a/test/GuardedExecutor.t.sol +++ b/test/GuardedExecutor.t.sol @@ -119,7 +119,9 @@ contract GuardedExecutorTest is BaseTest { vm.startPrank(d.eoa); d.d - .setSpendLimit(k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether); + .setSpendLimit( + k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether + ); vm.stopPrank(); u.nonce = d.d.getNonce(0); @@ -265,7 +267,9 @@ contract GuardedExecutorTest is BaseTest { vm.startPrank(d.eoa); d.d - .setSpendLimit(k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether); + .setSpendLimit( + k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether + ); vm.stopPrank(); u.nonce = d.d.getNonce(0);