Skip to content

Commit 1fe51b3

Browse files
authored
feat: add multicall to simulator (#402)
* feat: add multicall to simulator * chore: add gas test * chore: review fixes * chore: fmt contracts and update gas snapshots
1 parent 7dd8a5d commit 1fe51b3

File tree

6 files changed

+809
-7
lines changed

6 files changed

+809
-7
lines changed

src/LayerZeroSettler.sol

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,15 @@ contract LayerZeroSettler is OApp, ISettler, EIP712 {
102102
}
103103
}
104104

105-
function _getPeerOrRevert(uint32 /* _eid */) internal view virtual override returns (bytes32) {
105+
function _getPeerOrRevert(
106+
uint32 /* _eid */
107+
)
108+
internal
109+
view
110+
virtual
111+
override
112+
returns (bytes32)
113+
{
106114
// The peer address for all chains is automatically set to `address(this)`
107115
return bytes32(uint256(uint160(address(this))));
108116
}

src/Simulator.sol

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pragma solidity ^0.8.23;
33

44
import {ICommon} from "./interfaces/ICommon.sol";
5+
import {IMulticall3} from "./interfaces/IMulticall3.sol";
56
import {FixedPointMathLib as Math} from "solady/utils/FixedPointMathLib.sol";
67

78
/// @title Simulator
@@ -125,6 +126,99 @@ contract Simulator {
125126
return _callOrchestrator(oc, isStateOverride, data);
126127
}
127128

129+
/// @dev Performs a call to Multicall3 with calls followed by an orchestrator call.
130+
/// Returns the gas used parsed from the last Result in the multicall3 response.
131+
/// If parsing fails (gasUsed == 0), this function stores the orchestrator's error in memory
132+
/// so the caller can bubble it up using bubbleUpMulticall3Error.
133+
/// @param multicall3 The multicall3 contract address
134+
/// @param calls Array of Call3 structs to execute before the orchestrator call
135+
/// @param oc The orchestrator address
136+
/// @param isStateOverride Whether to use state override mode for the orchestrator call
137+
/// @param combinedGasOverride The combined gas override value
138+
/// @param u The Intent struct to pass to the orchestrator
139+
/// @return gasUsed The gas used by the orchestrator call (parsed from SimulationPassed or state override result)
140+
/// @return multicall3Gas The gas spent on the aggregate3 call itself
141+
/// @return lastReturnData The return data from the orchestrator call
142+
function _callMulticall3(
143+
address multicall3,
144+
IMulticall3.Call3[] memory calls,
145+
address oc,
146+
bool isStateOverride,
147+
uint256 combinedGasOverride,
148+
ICommon.Intent memory u
149+
)
150+
internal
151+
freeTempMemory
152+
returns (uint256 gasUsed, uint256 multicall3Gas, bytes memory lastReturnData)
153+
{
154+
// Build the orchestrator call data
155+
bytes memory orchestratorData = abi.encodeWithSignature(
156+
"simulateExecute(bool,uint256,bytes)",
157+
isStateOverride,
158+
combinedGasOverride,
159+
abi.encode(u)
160+
);
161+
162+
// Construct the full Call3[] array: calls + orchestrator call
163+
IMulticall3.Call3[] memory allCalls = new IMulticall3.Call3[](calls.length + 1);
164+
for (uint256 i = 0; i < calls.length; i++) {
165+
allCalls[i] = calls[i];
166+
}
167+
// Last call is to the orchestrator
168+
allCalls[calls.length] =
169+
IMulticall3.Call3({target: oc, allowFailure: true, callData: orchestratorData});
170+
171+
// Measure gas before and after aggregate3 call
172+
uint256 gasBefore = gasleft();
173+
IMulticall3.Result[] memory results = IMulticall3(multicall3).aggregate3(allCalls);
174+
multicall3Gas = gasBefore - gasleft();
175+
176+
// Get the last result (orchestrator call result)
177+
// Check all calls for failures (all results except the last one, which is the orchestrator call)
178+
if (results.length > 1) {
179+
for (uint256 i = 0; i < results.length - 1; i++) {
180+
// If any call failed, we return gasUsed = 0, multicall3Gas, and the error data from that call
181+
if (!results[i].success) {
182+
return (0, 0, results[i].returnData);
183+
}
184+
}
185+
}
186+
IMulticall3.Result memory lastResult = results[results.length - 1];
187+
lastReturnData = lastResult.returnData;
188+
189+
// Parse gasUsed from the orchestrator's return data
190+
// Assembly required for low-level parsing of return data
191+
assembly ("memory-safe") {
192+
let returnDataPtr := add(lastReturnData, 0x20)
193+
194+
switch isStateOverride
195+
case 0 {
196+
// If `isStateOverride` is false, the orchestrator reverts with SimulationPassed
197+
// so success will be false in multicall3's Result, but we still need to parse the revert data
198+
// Check for SimulationPassed selector (0x4f0c028c)
199+
// The `gasUsed` will be in the revert data at offset 0x04
200+
if eq(shr(224, mload(returnDataPtr)), 0x4f0c028c) {
201+
gasUsed := mload(add(returnDataPtr, 0x04))
202+
}
203+
}
204+
default {
205+
// If isStateOverride is true and call succeeded, gasUsed is at offset 0x00
206+
let lastSuccess := mload(lastResult)
207+
if lastSuccess {
208+
gasUsed := mload(returnDataPtr)
209+
}
210+
}
211+
}
212+
}
213+
214+
/// @dev Bubbles up the error from the multicall3's last result returnData.
215+
/// This is called when _callMulticall3 returns gasUsed == 0.
216+
function _bubbleUpMulticall3Error(bytes memory errorData) internal pure {
217+
assembly ("memory-safe") {
218+
revert(add(errorData, 0x20), mload(errorData))
219+
}
220+
}
221+
128222
/// @dev Simulate the gas usage for a user operation. This function reverts if the simulation fails.
129223
/// @param oc The orchestrator address
130224
/// @param overrideCombinedGas Whether to override the combined gas for the intent to type(uint256).max
@@ -262,4 +356,151 @@ contract Simulator {
262356
}
263357
}
264358
}
359+
360+
/// @dev Simulates the execution of an intent through Multicall3, with calls executed before the orchestrator call.
361+
/// Finds the combined gas by iteratively increasing it until the simulation passes.
362+
/// The start value for combinedGas is gasUsed + original combinedGas.
363+
/// Set u.combinedGas to add some starting offset to the gasUsed value.
364+
/// @param multicall3 The multicall3 contract address
365+
/// @param calls Array of Call3 structs to execute before the orchestrator call
366+
/// @param oc The orchestrator address
367+
/// @param paymentPerGasPrecision The precision of the payment per gas value.
368+
/// paymentAmount = gas * paymentPerGas / (10 ** paymentPerGasPrecision)
369+
/// @param paymentPerGas The amount of `paymentToken` to be added per gas unit.
370+
/// Total payment is calculated as paymentAmount += gasUsed * paymentPerGas.
371+
/// @dev Set paymentAmount to include any static offset to the gas value.
372+
/// @param combinedGasIncrement Basis Points increment to be added for each iteration of searching for combined gas.
373+
/// @dev The closer this number is to 10_000, the more precise combined gas will be. But more iterations will be needed.
374+
/// @dev This number should always be > 10_000, to get correct results.
375+
/// If the increment is too small, the function might run out of gas while finding the combined gas value.
376+
/// @param encodedIntent The encoded user operation
377+
/// @return gasUsed The gas used in the successful simulation
378+
/// @return multicall3Gas The gas spent on the aggregate3 call
379+
/// @return combinedGas The first combined gas value that gives a successful simulation.
380+
/// This function reverts if the primary simulation run with max combinedGas fails.
381+
/// If the primary run is successful, it iteratively increases u.combinedGas by `combinedGasIncrement` until the simulation passes.
382+
/// All failing simulations during this run are ignored.
383+
function simulateMulticall3CombinedGas(
384+
address multicall3,
385+
IMulticall3.Call3[] memory calls,
386+
address oc,
387+
uint8 paymentPerGasPrecision,
388+
uint256 paymentPerGas,
389+
uint256 combinedGasIncrement,
390+
bytes calldata encodedIntent
391+
) public payable virtual returns (uint256 gasUsed, uint256 multicall3Gas, uint256 combinedGas) {
392+
// Decode the intent first
393+
ICommon.Intent memory u = abi.decode(encodedIntent, (ICommon.Intent));
394+
395+
bytes memory errorData;
396+
397+
// 1. Primary Simulation Run to get initial gasUsed value with combinedGasOverride
398+
399+
(gasUsed, multicall3Gas, errorData) =
400+
_callMulticall3(multicall3, calls, oc, false, type(uint256).max, u);
401+
402+
// If the simulation failed, bubble up the orchestrator's error.
403+
if (gasUsed == 0) {
404+
_bubbleUpMulticall3Error(errorData);
405+
}
406+
407+
u.combinedGas += gasUsed;
408+
409+
_updatePaymentAmounts(u, u.combinedGas, paymentPerGasPrecision, paymentPerGas);
410+
411+
while (true) {
412+
(gasUsed, multicall3Gas, errorData) =
413+
_callMulticall3(multicall3, calls, oc, false, 0, u);
414+
415+
// If the simulation failed, check if it's a PaymentError and bubble it up.
416+
// PaymentError is given special treatment here, as it comes from
417+
// the account not having enough funds, and cannot be recovered from,
418+
// since the paymentAmount will keep increasing in this loop.
419+
if (gasUsed == 0 && errorData.length >= 4) {
420+
// errorData is a bytes memory containing the revert data from the orchestrator
421+
// Layout: [length (32 bytes)][selector (4 bytes)][additional data...]
422+
bytes4 errorSelector;
423+
assembly ("memory-safe") {
424+
// Load 32 bytes starting from errorData+32
425+
// bytes4 values are already right-aligned, no shift needed
426+
errorSelector := mload(add(errorData, 32))
427+
}
428+
if (errorSelector == 0xabab8fc9) {
429+
// PaymentError()
430+
431+
// Revert with just the selector (0x20 bytes = 4 bytes selector + 28 bytes padding)
432+
assembly ("memory-safe") {
433+
mstore(0x00, errorSelector)
434+
revert(0x00, 0x20)
435+
}
436+
}
437+
}
438+
439+
if (gasUsed != 0) {
440+
return (gasUsed, multicall3Gas, u.combinedGas);
441+
}
442+
443+
uint256 gasIncrement = Math.mulDiv(u.combinedGas, combinedGasIncrement, 10_000);
444+
445+
_updatePaymentAmounts(u, gasIncrement, paymentPerGasPrecision, paymentPerGas);
446+
447+
// Step up the combined gas, until we see a simulation passing
448+
u.combinedGas += gasIncrement;
449+
}
450+
}
451+
452+
/// @dev Same as simulateMulticall3CombinedGas, but with an additional verification run
453+
/// that generates a successful non reverting state override simulation.
454+
/// Which can be used in eth_simulateV1 to get the trace.
455+
/// @param multicall3 The multicall3 contract address
456+
/// @param calls Array of Call3 structs to execute before the orchestrator call
457+
/// @param oc The orchestrator address
458+
/// @param paymentPerGasPrecision The precision of the payment per gas value.
459+
/// paymentAmount = gas * paymentPerGas / (10 ** paymentPerGasPrecision)
460+
/// @param paymentPerGas The amount of `paymentToken` to be added per gas unit.
461+
/// @param combinedGasIncrement Basis Points increment to be added for each iteration of searching for combined gas.
462+
/// @param combinedGasVerificationOffset is a static value that is added after a successful combinedGas is found.
463+
/// This can be used to account for variations in sig verification gas, for keytypes like P256.
464+
/// @param encodedIntent The encoded user operation
465+
/// @return gasUsed The gas used in the successful simulation
466+
/// @return multicall3Gas The gas spent on the aggregate3 call
467+
/// @return combinedGas The combined gas value including the verification offset
468+
function simulateMulticall3V1Logs(
469+
address multicall3,
470+
IMulticall3.Call3[] memory calls,
471+
address oc,
472+
uint8 paymentPerGasPrecision,
473+
uint256 paymentPerGas,
474+
uint256 combinedGasIncrement,
475+
uint256 combinedGasVerificationOffset,
476+
bytes calldata encodedIntent
477+
) public payable virtual returns (uint256 gasUsed, uint256 multicall3Gas, uint256 combinedGas) {
478+
(gasUsed, multicall3Gas, combinedGas) = simulateMulticall3CombinedGas(
479+
multicall3,
480+
calls,
481+
oc,
482+
paymentPerGasPrecision,
483+
paymentPerGas,
484+
combinedGasIncrement,
485+
encodedIntent
486+
);
487+
488+
combinedGas += combinedGasVerificationOffset;
489+
490+
ICommon.Intent memory u = abi.decode(encodedIntent, (ICommon.Intent));
491+
492+
_updatePaymentAmounts(u, combinedGas, paymentPerGasPrecision, paymentPerGas);
493+
494+
u.combinedGas = combinedGas;
495+
496+
bytes memory errorData;
497+
498+
// Verification Run to generate the logs with the correct combinedGas and payment amounts.
499+
(gasUsed, multicall3Gas, errorData) = _callMulticall3(multicall3, calls, oc, true, 0, u);
500+
501+
// If the simulation failed, bubble up the orchestrator's error
502+
if (gasUsed == 0) {
503+
_bubbleUpMulticall3Error(errorData);
504+
}
505+
}
265506
}

src/interfaces/IMulticall3.sol

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
/// @notice Interface for Multicall3 contract
5+
interface IMulticall3 {
6+
struct Call {
7+
address target;
8+
bytes callData;
9+
}
10+
11+
struct Call3 {
12+
address target;
13+
bool allowFailure;
14+
bytes callData;
15+
}
16+
17+
struct Call3Value {
18+
address target;
19+
bool allowFailure;
20+
uint256 value;
21+
bytes callData;
22+
}
23+
24+
struct Result {
25+
bool success;
26+
bytes returnData;
27+
}
28+
29+
function aggregate(Call[] calldata calls)
30+
external
31+
payable
32+
returns (uint256 blockNumber, bytes[] memory returnData);
33+
34+
function aggregate3(Call3[] calldata calls)
35+
external
36+
payable
37+
returns (Result[] memory returnData);
38+
}

test/Benchmark.t.sol

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,11 +1772,16 @@ contract BenchmarkTest is BaseTest {
17721772
vm.startPrank(d.eoa);
17731773
d.d.authorize(k.k);
17741774
d.d
1775-
.setCanExecute(
1776-
k.keyHash, address(paymentToken), bytes4(keccak256("transfer(address,uint256)")), true
1777-
);
1775+
.setCanExecute(
1776+
k.keyHash,
1777+
address(paymentToken),
1778+
bytes4(keccak256("transfer(address,uint256)")),
1779+
true
1780+
);
17781781
d.d
1779-
.setSpendLimit(k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Hour, 1 ether);
1782+
.setSpendLimit(
1783+
k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Hour, 1 ether
1784+
);
17801785
d.d.setSpendLimit(k.keyHash, address(0), GuardedExecutor.SpendPeriod.Hour, 1 ether);
17811786
vm.stopPrank();
17821787

test/GuardedExecutor.t.sol

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ contract GuardedExecutorTest is BaseTest {
119119

120120
vm.startPrank(d.eoa);
121121
d.d
122-
.setSpendLimit(k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether);
122+
.setSpendLimit(
123+
k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether
124+
);
123125
vm.stopPrank();
124126

125127
u.nonce = d.d.getNonce(0);
@@ -265,7 +267,9 @@ contract GuardedExecutorTest is BaseTest {
265267

266268
vm.startPrank(d.eoa);
267269
d.d
268-
.setSpendLimit(k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether);
270+
.setSpendLimit(
271+
k.keyHash, address(paymentToken), GuardedExecutor.SpendPeriod.Day, 1 ether
272+
);
269273
vm.stopPrank();
270274

271275
u.nonce = d.d.getNonce(0);

0 commit comments

Comments
 (0)