Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 116 additions & 3 deletions src/account/ModularAccountBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ abstract contract ModularAccountBase is
error DeferredValidationHasValidationHooks();

// Wraps execution of a native function with runtime validation and hooks
// Used for performCreate, execute, executeBatch, installExecution, uninstallExecution, installValidation,
// uninstallValidation, upgradeToAndCall, updateFallbackSignerData.
// Used for performCreate, execute, executeBatch, executeWithPreCalls, installExecution, uninstallExecution,
// installValidation, uninstallValidation, upgradeToAndCall, updateFallbackSignerData.
modifier wrapNativeFunction() {
DensePostHookData postHookData = _checkPermittedCallerAndAssociatedHooks();

Expand Down Expand Up @@ -263,6 +263,65 @@ abstract contract ModularAccountBase is
}
}

/// @inheritdoc IModularAccountBase
/// @notice May be validated by a global validation.
function executeWithPreCalls(Call[] calldata preCalls, Call[] calldata calls)
external
payable
override
wrapNativeFunction
returns (bool success, bytes[] memory results)
{
// Phase 1: PreCalls — revert on any failure (same as executeBatch)
uint256 preCallsLength = preCalls.length;
for (uint256 i = 0; i < preCallsLength; ++i) {
ExecutionLib.callBubbleOnRevertTransient(preCalls[i].target, preCalls[i].value, preCalls[i].data);
}

// Phase 2: Calls — catch failures atomically via self-call
uint256 callsLength = calls.length;
if (callsLength > 0) {
// Self-call performBatchCall to get an atomic revert boundary.
// performBatchCall is a dedicated helper that only allows self-calls and runs no hooks,
// avoiding double-execution of hooks that would occur if we self-called executeBatch.
// solhint-disable-next-line avoid-low-level-calls
(bool callSuccess, bytes memory returnData) =
address(this).call(abi.encodeCall(this.performBatchCall, (calls)));

if (callSuccess) {
success = true;
results = abi.decode(returnData, (bytes[]));
}
// else: success = false (default), results = empty (default)
} else {
success = true;
results = new bytes[](0);
}

emit ExecuteWithPreCallsResult(success, results);
}

/// @inheritdoc IModularAccountBase
/// @dev Only callable by the account itself. Executes calls without any hooks or validation,
/// providing a clean revert boundary for executeWithPreCalls.
function performBatchCall(Call[] calldata calls)
external
payable
override
returns (bytes[] memory results)
{
if (msg.sender != address(this)) {
revert UnrecognizedFunction(msg.sig);
}

uint256 callsLength = calls.length;
results = new bytes[](callsLength);
for (uint256 i = 0; i < callsLength; ++i) {
ExecutionLib.callBubbleOnRevertTransient(calls[i].target, calls[i].value, calls[i].data);
results[i] = ExecutionLib.collectReturnData();
}
}

/// @inheritdoc IModularAccount
function executeWithRuntimeValidation(bytes calldata data, bytes calldata authorization)
external
Expand Down Expand Up @@ -895,6 +954,10 @@ abstract contract ModularAccountBase is
// If this is done, we must ensure all of the inner calls are allowed by the provided validation
// function.
_checkExecuteBatchValidationApplicability(callData[4:], validationFunction, checkingType);
} else if (outerSelector == IModularAccountBase.executeWithPreCalls.selector) {
// executeWithPreCalls has two Call[] arrays: preCalls and calls.
// Both need the same self-call validation checks as executeBatch.
_checkExecuteWithPreCallsValidationApplicability(callData[4:], validationFunction, checkingType);
}
}

Expand Down Expand Up @@ -1042,7 +1105,10 @@ abstract contract ModularAccountBase is
selector := shr(224, calldataload(dataOffset))
}

if (selector == uint32(this.execute.selector) || selector == uint32(this.executeBatch.selector)) {
if (
selector == uint32(this.execute.selector) || selector == uint32(this.executeBatch.selector)
|| selector == uint32(this.executeWithPreCalls.selector)
) {
// To prevent arbitrarily-deep recursive checking, we limit the depth of self-calls to one
// for the purposes of batching.
// This means that all self-calls must occur at the top level of the batch.
Expand All @@ -1058,6 +1124,53 @@ abstract contract ModularAccountBase is
}
}

/// @notice Checks if the validation function is allowed to perform this call to `executeWithPreCalls`.
/// @dev Decodes two Call[] arrays (preCalls and calls) and validates both using the same rules as
/// executeBatch.
/// @param callData The calldata to check, excluding the `executeWithPreCalls` selector.
/// @param validationFunction The validation function to check against.
/// @param checkingType The type of validation checking to perform.
function _checkExecuteWithPreCallsValidationApplicability(
bytes calldata callData,
ValidationLookupKey validationFunction,
ValidationCheckingType checkingType
) internal view {
// The calldata encodes two Call[] arrays: (Call[] preCalls, Call[] calls).
// We decode and validate both arrays using the same logic as executeBatch.
// solhint-disable-next-line no-inline-assembly
(Call[] memory preCalls, Call[] memory calls) = abi.decode(callData, (Call[], Call[]));

_checkCallArrayValidation(preCalls, validationFunction, checkingType);
_checkCallArrayValidation(calls, validationFunction, checkingType);
}

/// @notice Shared helper to validate a Call[] array for self-call restrictions.
function _checkCallArrayValidation(
Call[] memory calls,
ValidationLookupKey validationFunction,
ValidationCheckingType checkingType
) internal view {
for (uint256 i = 0; i < calls.length; ++i) {
if (calls[i].target == address(this)) {
if (calls[i].data.length < 4) {
revert UnrecognizedFunction(bytes4(calls[i].data));
}

bytes4 selector = bytes4(calls[i].data);

if (
selector == IModularAccount.execute.selector
|| selector == IModularAccount.executeBatch.selector
|| selector == IModularAccountBase.executeWithPreCalls.selector
) {
revert SelfCallRecursionDepthExceeded();
}

_checkIfValidationAppliesSelector(selector, validationFunction, checkingType);
}
}
}

function _checkIfValidationAppliesSelector(
bytes4 selector,
ValidationLookupKey validationFunction,
Expand Down
2 changes: 2 additions & 0 deletions src/account/ModularAccountView.sol
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ abstract contract ModularAccountView is IModularAccountView {
|| selector == uint32(IModularAccount.accountId.selector)
|| selector == uint32(IModularAccountView.getExecutionData.selector)
|| selector == uint32(IModularAccountView.getValidationData.selector)
|| selector == uint32(IModularAccountBase.performBatchCall.selector)
|| selector == uint32(UUPSUpgradeable.proxiableUUID.selector));
}

Expand All @@ -118,6 +119,7 @@ abstract contract ModularAccountView is IModularAccountView {
function _isWrappedNativeFunction(uint32 selector) internal pure virtual returns (bool) {
return (selector == uint32(IModularAccount.execute.selector)
|| selector == uint32(IModularAccount.executeBatch.selector)
|| selector == uint32(IModularAccountBase.executeWithPreCalls.selector)
|| selector == uint32(IModularAccount.installExecution.selector)
|| selector == uint32(IModularAccount.installValidation.selector)
|| selector == uint32(IModularAccount.uninstallExecution.selector)
Expand Down
26 changes: 26 additions & 0 deletions src/interfaces/IModularAccountBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@

pragma solidity ^0.8.28;

import {Call} from "@erc6900/reference-implementation/interfaces/IModularAccount.sol";

interface IModularAccountBase {
/// @notice Emitted when executeWithPreCalls completes. Always emitted regardless of whether calls succeeded.
/// @param success Whether the calls batch succeeded.
/// @param results The return data from the calls batch, empty if calls reverted.
event ExecuteWithPreCallsResult(bool indexed success, bytes[] results);

/// @notice Create a contract.
/// @param value The value to send to the new contract constructor
/// @param initCode The initCode to deploy.
Expand All @@ -28,4 +35,23 @@ interface IModularAccountBase {
external
payable
returns (address createdAddr);

/// @notice Execute a two-phase batch: preCalls must all succeed (or the entire call reverts), then calls are
/// executed atomically — if any call in the calls batch fails, the calls batch reverts but the overall
/// function still succeeds. An event is emitted with the result.
/// @param preCalls The array of calls that must succeed. Reverts if any fail.
/// @param calls The array of calls to attempt. Failures are caught and reported via event.
/// @return success Whether the calls batch succeeded.
/// @return results The return data from the calls batch, empty if calls reverted.
function executeWithPreCalls(Call[] calldata preCalls, Call[] calldata calls)
external
payable
returns (bool success, bytes[] memory results);

/// @notice Internal batch execution helper, only callable by the account itself.
/// @dev Used by executeWithPreCalls to create an atomic revert boundary without triggering
/// executeBatch's selector-associated hooks.
/// @param calls The array of calls to execute.
/// @return results The return data from the calls.
function performBatchCall(Call[] calldata calls) external payable returns (bytes[] memory results);
}
23 changes: 21 additions & 2 deletions src/modules/permissions/AllowlistModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interface
import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import {ModularAccountBase} from "../../account/ModularAccountBase.sol";
import {ModuleBase} from "../../modules/ModuleBase.sol";

/// @title Allowlist with ERC-20 Spend Limit Module
Expand Down Expand Up @@ -139,6 +140,14 @@ contract AllowlistModule is IExecutionHookModule, IValidationHookModule, ModuleB
for (uint256 i = 0; i < calls.length; ++i) {
_decrementLimitIfApplies(entityId, calls[i].target, calls[i].data);
}
} else if (selector == ModularAccountBase.executeWithPreCalls.selector) {
(Call[] memory preCalls, Call[] memory calls) = abi.decode(callData, (Call[], Call[]));
for (uint256 i = 0; i < preCalls.length; ++i) {
_decrementLimitIfApplies(entityId, preCalls[i].target, preCalls[i].data);
}
for (uint256 i = 0; i < calls.length; ++i) {
_decrementLimitIfApplies(entityId, calls[i].target, calls[i].data);
}
} else {
revert SpendingRequestNotAllowed(selector);
}
Expand Down Expand Up @@ -286,8 +295,8 @@ contract AllowlistModule is IExecutionHookModule, IValidationHookModule, ModuleB
/// revert.
/// @param entityId The entity ID to check the allowlist status for.
/// @param callDataWithoutSelector The call payload to check the allowlist status for. This should be a call to
/// either
/// `IModularAccount.execute`, or `IModularAccount.executeBatch` without selector.
/// `IModularAccount.execute`, `IModularAccount.executeBatch`, or
/// `IModularAccountBase.executeWithPreCalls` without selector.
function checkAllowlistCalldata(bytes4 selector, uint32 entityId, bytes memory callDataWithoutSelector)
public
view
Expand All @@ -298,6 +307,16 @@ contract AllowlistModule is IExecutionHookModule, IValidationHookModule, ModuleB
} else if (selector == IModularAccount.executeBatch.selector) {
Call[] memory calls = abi.decode(callDataWithoutSelector, (Call[]));

for (uint256 i = 0; i < calls.length; ++i) {
_checkCallPermission(entityId, msg.sender, calls[i].target, calls[i].data);
}
} else if (selector == ModularAccountBase.executeWithPreCalls.selector) {
(Call[] memory preCalls, Call[] memory calls) =
abi.decode(callDataWithoutSelector, (Call[], Call[]));

for (uint256 i = 0; i < preCalls.length; ++i) {
_checkCallPermission(entityId, msg.sender, preCalls[i].target, preCalls[i].data);
}
for (uint256 i = 0; i < calls.length; ++i) {
_checkCallPermission(entityId, msg.sender, calls[i].target, calls[i].data);
}
Expand Down
11 changes: 10 additions & 1 deletion src/modules/permissions/NativeTokenLimitModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {IERC165, ModuleBase} from "../ModuleBase.sol";
/// @notice This module supports a total native token spend limit across User Operation gas and native transfers.
/// - None of the functions are installed on the account. Account states are to be retrieved from this global
/// singleton directly.
/// - This module only tracks native transfers for the 3 functions `execute`, `executeBatch`, `performCreate`.
/// - This module only tracks native transfers for the 4 functions `execute`, `executeBatch`,
/// `executeWithPreCalls`, `performCreate`.
/// - By default, using a paymaster in a UO would cause the limit to not decrease. If an account uses a special
/// paymaster that converts non-native tokens in the account to pay for gas, this paymaster should be added to
/// the `specialPaymasters` list to enable the correct accounting of spend limits. When these paymasters are used
Expand Down Expand Up @@ -106,6 +107,14 @@ contract NativeTokenLimitModule is ModuleBase, IExecutionHookModule, IValidation
for (uint256 i = 0; i < calls.length; ++i) {
value += calls[i].value;
}
} else if (selector == ModularAccountBase.executeWithPreCalls.selector) {
(Call[] memory preCalls, Call[] memory calls) = abi.decode(callData, (Call[], Call[]));
for (uint256 i = 0; i < preCalls.length; ++i) {
value += preCalls[i].value;
}
for (uint256 i = 0; i < calls.length; ++i) {
value += calls[i].value;
}
} else if (selector == ModularAccountBase.performCreate.selector) {
value = abi.decode(callData, (uint256));
}
Expand Down
Loading
Loading