Skip to content
Draft
44 changes: 44 additions & 0 deletions src/Batcher.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {IOrchestrator} from "./interfaces/IOrchestrator.sol";

/// @title Batcher
/// @notice Contract for executing multiple intents through the orchestrator in a single transaction
/// @dev Individual intent failures do not revert the entire batch
contract Batcher {
/// @notice Emitted when an intent execution fails
event IntentFailed(uint256 indexed index, bytes reason);

error InvalidArrayLength();
error ArrayLengthMismatch();

/// @notice Execute multiple intents through the orchestrator
/// @param orchestrator The orchestrator contract address
/// @param encodedIntents Array of encoded intents
/// @param intentGas Array of gas amounts for each intent
function batchExecute(
address orchestrator,
bytes[] calldata encodedIntents,
uint256[] calldata intentGas
) external {
uint256 length = encodedIntents.length;

if (length == 0) revert InvalidArrayLength();
if (length != intentGas.length) revert ArrayLengthMismatch();

// Execute each intent
for (uint256 i = 0; i < length; ++i) {
// Encode the function call
bytes memory data =
abi.encodeWithSelector(IOrchestrator.execute.selector, encodedIntents[i]);

// Make the call with specified gas
(bool success, bytes memory returnData) = orchestrator.call{gas: intentGas[i]}(data);

if (!success) {
emit IntentFailed(i, returnData);
}
}
}
}
7 changes: 0 additions & 7 deletions src/IthacaAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -627,13 +627,6 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor {
// Set the target key hash to the payer's.
keyHash = k;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simulation Logic Removal - Security Improvement

Excellent removal of simulation-specific logic from production contract!

Security benefits:

  1. Eliminates attack surface: Removes special-case logic that could be exploited
  2. Cleaner audit trail: Production contract behavior is now deterministic
  3. Separation of concerns: Simulation logic moved to dedicated simulation contracts

The removal of the address(ORCHESTRATOR).balance == type(uint256).max check is particularly good - this was a hacky way to detect simulation mode that could potentially be gamed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like this removal. now the simulation functionality is more isolated.

// If this is a simulation, signature validation errors are skipped.
/// @dev to simulate a paymaster, state override the balance of the msg.sender
/// to type(uint256).max. In this case, the msg.sender is the ORCHESTRATOR.
if (address(ORCHESTRATOR).balance == type(uint256).max) {
isValid = true;
}

if (!isValid) {
revert Unauthorized();
}
Expand Down
340 changes: 81 additions & 259 deletions src/Orchestrator.sol

Large diffs are not rendered by default.

174 changes: 80 additions & 94 deletions src/Simulator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,89 +41,76 @@ contract Simulator {
}

/// @dev Performs a call to the Orchestrator, and returns the gas used by the Intent.
/// This function expects that the `data` is correctly encoded.
function _callOrchestrator(address oc, bool isStateOverride, bytes memory data)
internal
returns (uint256 gasUsed)
{
/// This function is for forwarding the re-encoded Intent.
function _callOrchestrator(
address oc,
bool isRevert,
uint256 combinedGasOverride,
ICommon.Intent memory i
) internal freeTempMemory returns (uint256 gasUsed, uint256 accountExecuteGas) {
bytes memory data =
abi.encodeWithSignature("simulateExecute(bool,bytes)", isRevert, abi.encode(i));

assembly ("memory-safe") {
// Zeroize return slots.
mstore(0x00, 0)
mstore(0x20, 0)

let success := call(gas(), oc, 0, add(data, 0x20), mload(data), 0x00, 0x40)
gasUsed := gas()

let success :=
call(combinedGasOverride, oc, 0, add(data, 0x20), mload(data), 0x00, 0x40)

switch isStateOverride
gasUsed := sub(gas(), gasUsed)

switch isRevert
case 0 {
// If `isStateOverride` is false, the call reverts, and we check for
// If `isRevert` is false, the call reverts, and we check for
// the `SimulationPassed` selector instead of `success`.
// The `gasUsed` will be returned by the revert, at 0x04 in the return data.
if eq(shr(224, mload(0x00)), 0x4f0c028c) { gasUsed := mload(0x04) }
// The `accountExecuteGas` will be returned by the revert, at 0x04 in the return data.
if eq(shr(224, mload(0x00)), 0x4f0c028c) { accountExecuteGas := mload(0x04) }
}
default {
// If the call is successful, the `gasUsed` is at 0x00 in the return data.
if success { gasUsed := mload(0x00) }
// If the call is successful, the `accountExecuteGas` is at 0x00 in the return data.
if success { accountExecuteGas := mload(0x00) }
}
}
}

/// @dev Performs a call to the Orchestrator, and returns the gas used by the Intent.
/// This function is for directly forwarding the Intent in the calldata.
function _callOrchestratorCalldata(
address oc,
bool isStateOverride,
uint256 combinedGasOverride,
bytes calldata encodedIntent
) internal freeTempMemory returns (uint256) {
bytes memory data = abi.encodeWithSignature(
"simulateExecute(bool,uint256,bytes)",
isStateOverride,
combinedGasOverride,
encodedIntent
);
return _callOrchestrator(oc, isStateOverride, data);
return (gasUsed, accountExecuteGas);
}

/// @dev Performs a call to the Orchestrator, and returns the gas used by the Intent.
/// This function is for forwarding the re-encoded Intent.
function _callOrchestratorMemory(
function simulateAccountExecuteGas(
address oc,
bool isStateOverride,
uint256 combinedGasOverride,
ICommon.Intent memory u
) internal freeTempMemory returns (uint256) {
bytes memory data = abi.encodeWithSignature(
"simulateExecute(bool,uint256,bytes)",
isStateOverride,
combinedGasOverride,
abi.encode(u)
);
return _callOrchestrator(oc, isStateOverride, data);
}
uint256 accountExecuteGasIncrement,
ICommon.Intent memory i
) public payable virtual returns (uint256 accountExecuteGas) {
uint256 accountExecuteGasPreOffset = i.accountExecuteGas;

/// @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
/// @param encodedIntent The encoded user operation
/// @return gasUsed The amount of gas used by the simulation
function simulateGasUsed(address oc, bool overrideCombinedGas, bytes calldata encodedIntent)
public
payable
virtual
returns (uint256 gasUsed)
{
gasUsed = _callOrchestratorCalldata(
oc, false, Math.ternary(overrideCombinedGas, type(uint256).max, 0), encodedIntent
);
// 1. Primary Simulation Run to get initial accountExecuteGas value with combinedGasOverride
i.accountExecuteGas = type(uint256).max;
(, accountExecuteGas) = _callOrchestrator(oc, false, type(uint256).max, i);

// If the simulation failed, bubble up full revert.
// If the simulation failed, bubble up the full revert.
assembly ("memory-safe") {
if iszero(gasUsed) {
if iszero(accountExecuteGas) {
let m := mload(0x40)
returndatacopy(m, 0x00, returndatasize())
revert(m, returndatasize())
}
}

i.accountExecuteGas = accountExecuteGas + accountExecuteGasPreOffset;

while (true) {
(, accountExecuteGas) = _callOrchestrator(oc, false, type(uint256).max, i);

if (accountExecuteGas != 0) {
return (i.accountExecuteGas);
}

i.accountExecuteGas +=
Math.mulDiv(i.accountExecuteGas, accountExecuteGasIncrement, 10_000);
}
}

/// @dev Simulates the execution of a intent, and finds the combined gas by iteratively increasing it until the simulation passes.
Expand All @@ -140,8 +127,6 @@ contract Simulator {
/// @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 itertively increases u.combinedGas by `combinedGasIncrement` until the simulation passes.
Expand All @@ -152,33 +137,33 @@ contract Simulator {
uint8 paymentPerGasPrecision,
uint256 paymentPerGas,
uint256 combinedGasIncrement,
bytes calldata encodedIntent
) public payable virtual returns (uint256 gasUsed, uint256 combinedGas) {
ICommon.Intent memory i
) public payable virtual returns (uint256 combinedGas) {
i.accountExecuteGas = type(uint256).max;

// 1. Primary Simulation Run to get initial gasUsed value with combinedGasOverride
gasUsed = _callOrchestratorCalldata(oc, false, type(uint256).max, encodedIntent);
(uint256 gasUsed, uint256 accountExecuteGas) =
_callOrchestrator(oc, false, type(uint256).max, i);

// If the simulation failed, bubble up the full revert.
assembly ("memory-safe") {
if iszero(gasUsed) {
if iszero(accountExecuteGas) {
let m := mload(0x40)
returndatacopy(m, 0x00, returndatasize())
revert(m, returndatasize())
}
}

// Update payment amounts using the gasUsed value
ICommon.Intent memory u = abi.decode(encodedIntent, (ICommon.Intent));
combinedGas += gasUsed;

u.combinedGas += gasUsed;

_updatePaymentAmounts(u, isPrePayment, u.combinedGas, paymentPerGasPrecision, paymentPerGas);
_updatePaymentAmounts(i, isPrePayment, combinedGas, paymentPerGasPrecision, paymentPerGas);

while (true) {
gasUsed = _callOrchestratorMemory(oc, false, 0, u);
(gasUsed, accountExecuteGas) = _callOrchestrator(oc, false, 0, i);

// If the simulation failed, bubble up the full revert.
assembly ("memory-safe") {
if iszero(gasUsed) {
if iszero(accountExecuteGas) {
let m := mload(0x40)
returndatacopy(m, 0x00, returndatasize())
// `PaymentError` is given special treatment here, as it comes from
Expand All @@ -188,25 +173,26 @@ contract Simulator {
}
}

if (gasUsed != 0) {
return (gasUsed, u.combinedGas);
if (accountExecuteGas != 0) {
return combinedGas;
}

uint256 gasIncrement = Math.mulDiv(u.combinedGas, combinedGasIncrement, 10_000);
uint256 gasIncrement = Math.mulDiv(combinedGas, combinedGasIncrement, 10_000);

_updatePaymentAmounts(
u, isPrePayment, gasIncrement, paymentPerGasPrecision, paymentPerGas
i, isPrePayment, gasIncrement, paymentPerGasPrecision, paymentPerGas
);

// Step up the combined gas, until we see a simulation passing
u.combinedGas += gasIncrement;
combinedGas += gasIncrement;
}
}

/// @dev Same as simulateCombinedGas, 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 combinedGasVerificationOffset is a static value that is added after a succesful combinedGas is found.
/// @param accountExecuteGasOffset is a static value that is added after a succesful combinedGas is found.
/// @param combinedGasOffset is a static value that is added after a succesful accountExecuteGas is found.
/// This can be used to account for variations in sig verification gas, for keytypes like P256.
/// @param paymentPerGasPrecision The precision of the payment per gas value.
/// paymentAmount = gas * paymentPerGas / (10 ** paymentPerGasPrecision)
Expand All @@ -216,36 +202,36 @@ contract Simulator {
uint8 paymentPerGasPrecision,
uint256 paymentPerGas,
uint256 combinedGasIncrement,
uint256 combinedGasVerificationOffset,
uint256 combinedGasOffset,
uint256 accountExecuteGasIncrement,
uint256 accountExecuteGasOffset,
bytes calldata encodedIntent
) public payable virtual returns (uint256 gasUsed, uint256 combinedGas) {
(gasUsed, combinedGas) = simulateCombinedGas(
oc,
isPrePayment,
paymentPerGasPrecision,
paymentPerGas,
combinedGasIncrement,
encodedIntent
);
) public payable virtual returns (uint256, /*accountExecuteGas*/ uint256 combinedGas) {
ICommon.Intent memory i = abi.decode(encodedIntent, (ICommon.Intent));

combinedGas += combinedGasVerificationOffset;
i.accountExecuteGas = simulateAccountExecuteGas(oc, accountExecuteGasIncrement, i);
i.accountExecuteGas += accountExecuteGasOffset;

ICommon.Intent memory u = abi.decode(encodedIntent, (ICommon.Intent));
combinedGas = simulateCombinedGas(
oc, isPrePayment, paymentPerGasPrecision, paymentPerGas, combinedGasIncrement, i
);

_updatePaymentAmounts(u, isPrePayment, combinedGas, paymentPerGasPrecision, paymentPerGas);
combinedGas += combinedGasOffset;

u.combinedGas = combinedGas;
_updatePaymentAmounts(i, isPrePayment, combinedGas, paymentPerGasPrecision, paymentPerGas);

// Verification Run to generate the logs with the correct combinedGas and payment amounts.
gasUsed = _callOrchestratorMemory(oc, true, 0, u);
(, uint256 accountExecuteGas) = _callOrchestrator(oc, true, 0, i);

// If the simulation failed, bubble up full revert
assembly ("memory-safe") {
if iszero(gasUsed) {
if iszero(accountExecuteGas) {
let m := mload(0x40)
returndatacopy(m, 0x00, returndatasize())
revert(m, returndatasize())
}
}

return (i.accountExecuteGas, combinedGas);
}
}
4 changes: 2 additions & 2 deletions src/interfaces/ICommon.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ interface ICommon {
uint256 prePaymentMaxAmount;
/// @dev The maximum amount of the token to pay.
uint256 totalPaymentMaxAmount;
/// @dev The combined gas limit for payment, verification, and calling the EOA.
uint256 combinedGas;
/// @dev The gas provided to the execute call on the account.
uint256 accountExecuteGas;
/// @dev Optional array of encoded SignedCalls that will be verified and executed
/// before the validation of the overall Intent.
/// A PreCall will NOT have its gas limit or payment applied.
Expand Down
26 changes: 1 addition & 25 deletions src/interfaces/IOrchestrator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,7 @@ import {ICommon} from "../interfaces/ICommon.sol";
interface IOrchestrator is ICommon {
/// @dev Executes a single encoded intent.
/// @param encodedIntent The encoded intent
/// @return err The error selector (non-zero if there is an error)

function execute(bytes calldata encodedIntent) external payable returns (bytes4 err);

/// @dev Executes an array of encoded intents.
/// @param encodedIntents Array of encoded intents
/// @return errs Array of error selectors (non-zero if there are errors)
function execute(bytes[] calldata encodedIntents)
external
payable
returns (bytes4[] memory errs);

/// @dev Minimal function, to allow hooking into the _execute function with the simulation flags set to true.
/// When simulationFlags is set to true, all errors are bubbled up. Also signature verification always returns true.
/// But the codepaths for signature verification are still hit, for correct gas measurement.
/// @dev If `isStateOverride` is false, then this function will always revert. If the simulation is successful, then it reverts with `SimulationPassed` error.
/// If `isStateOverride` is true, then this function will not revert if the simulation is successful.
/// But the balance of msg.sender has to be equal to type(uint256).max, to prove that a state override has been made offchain,
/// and this is not an onchain call. This mode has been added so that receipt logs can be generated for `eth_simulateV1`
/// @return gasUsed The amount of gas used by the execution. (Only returned if `isStateOverride` is true)
function simulateExecute(
bool isStateOverride,
uint256 combinedGasOverride,
bytes calldata encodedIntent
) external payable returns (uint256 gasUsed);
function execute(bytes calldata encodedIntent) external payable;

/// @dev Allows the orchestrator owner to withdraw tokens.
/// @param token The token address (0 for native token)
Expand Down
Loading