From b88318d42237297fd8947f17863aa3a34f92d5d4 Mon Sep 17 00:00:00 2001 From: Ryan <81343914+McOso@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:04:05 -0400 Subject: [PATCH 1/2] feat: add gas optimized dm --- lib/erc7579-implementation | 2 +- src/EIP7702/EIP7702MultiManagerDeleGator.sol | 39 ++ .../EIP7702MultiManagerDeleGatorCore.sol | 345 ++++++++++ src/SimpleDelegationManager.sol | 289 +++++++++ src/libraries/HookFlagsLib.sol | 44 ++ .../OptimizedDelegationManagerBenchmark.t.sol | 400 ++++++++++++ test/SimpleDelegationManagerComparison.t.sol | 611 ++++++++++++++++++ 7 files changed, 1729 insertions(+), 1 deletion(-) create mode 100644 src/EIP7702/EIP7702MultiManagerDeleGator.sol create mode 100644 src/EIP7702/EIP7702MultiManagerDeleGatorCore.sol create mode 100644 src/SimpleDelegationManager.sol create mode 100644 src/libraries/HookFlagsLib.sol create mode 100644 test/OptimizedDelegationManagerBenchmark.t.sol create mode 100644 test/SimpleDelegationManagerComparison.t.sol diff --git a/lib/erc7579-implementation b/lib/erc7579-implementation index 16138d1a..42aa5383 160000 --- a/lib/erc7579-implementation +++ b/lib/erc7579-implementation @@ -1 +1 @@ -Subproject commit 16138d1afd4e9711f6c1425133538837bd7787b5 +Subproject commit 42aa538397138e0858bae09d1bd1a1921aa24b8c diff --git a/src/EIP7702/EIP7702MultiManagerDeleGator.sol b/src/EIP7702/EIP7702MultiManagerDeleGator.sol new file mode 100644 index 00000000..1a3c15c4 --- /dev/null +++ b/src/EIP7702/EIP7702MultiManagerDeleGator.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +import { EIP7702MultiManagerDeleGatorCore } from "./EIP7702MultiManagerDeleGatorCore.sol"; +import { ERC1271Lib } from "../libraries/ERC1271Lib.sol"; + +/** + * @title EIP7702 Multi-Manager Stateless DeleGator Contract + * @notice An EIP-7702 account that approves a SET of DelegationManagers, instead of the single immutable + * manager baked into {EIP7702StatelessDeleGator}. + * @dev Same recover-to-self ECDSA signature scheme as {EIP7702StatelessDeleGator}: the signer that controls the account MUST + * be the EIP-7702 EOA. The approved-manager set lives in EIP-7201 namespaced storage on the EOA (see the core). This + * account is NOT ERC-4337 compatible (no EntryPoint / UserOperation support). + */ +contract EIP7702MultiManagerDeleGator is EIP7702MultiManagerDeleGatorCore { + ////////////////////////////// State ////////////////////////////// + + /// @dev The name of the contract + string public constant NAME = "EIP7702MultiManagerDeleGator"; + + /// @dev The version of the contract + string public constant VERSION = "1.3.0"; + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @notice Verifies that the signature was produced by the EIP-7702 EOA (i.e. this account's address). + * @param _hash The data signed + * @param _signature A 65-byte signature produced by the EIP-7702 EOA + * @return The EIP1271 magic value if the signature is valid, otherwise the failure value. + */ + function _isValidSignature(bytes32 _hash, bytes calldata _signature) internal view override returns (bytes4) { + if (ECDSA.recover(_hash, _signature) == address(this)) return ERC1271Lib.EIP1271_MAGIC_VALUE; + + return ERC1271Lib.SIG_VALIDATION_FAILED; + } +} diff --git a/src/EIP7702/EIP7702MultiManagerDeleGatorCore.sol b/src/EIP7702/EIP7702MultiManagerDeleGatorCore.sol new file mode 100644 index 00000000..110ec8d7 --- /dev/null +++ b/src/EIP7702/EIP7702MultiManagerDeleGatorCore.sol @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { IERC1155Receiver } from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ExecutionHelper } from "@erc7579/core/ExecutionHelper.sol"; + +import { IDeleGatorCore } from "../interfaces/IDeleGatorCore.sol"; +import { IDelegationManager } from "../interfaces/IDelegationManager.sol"; +import { IERC7821 } from "../interfaces/IERC7821.sol"; +import { CallType, ExecType, ModeSelector, ModePayload, Execution, Delegation, ModeCode } from "../utils/Types.sol"; +import { CALLTYPE_SINGLE, CALLTYPE_BATCH, EXECTYPE_DEFAULT, EXECTYPE_TRY, MODE_DEFAULT } from "../utils/Constants.sol"; + +/// @custom:storage-location erc7201:DeleGator.EIP7702MultiManager +struct EIP7702MultiManagerStorage { + // Set of DelegationManager addresses approved to call `executeFromExecutor` on this account (have root access). + mapping(address delegationManager => bool approved) isApprovedDelegationManager; +} + +/** + * @title EIP7702MultiManagerDeleGatorCore + * @notice A multi-manager EIP-7702 account. + * @dev Where {EIP7702DeleGatorCore} hard-codes a single `immutable delegationManager`, this core stores a SET of approved + * DelegationManagers in EIP-7201 namespaced storage. `onlyDelegationManager` is a set-membership check, so one EIP-7702 + * account can authorize both the canonical `DelegationManager` and a specialized `SimpleDelegationManager` (plus future + * managers) at once and route each action to the right one — no re-delegating and no switching between 7702 accounts. + * @dev This account is NOT ERC-4337 compatible: all EntryPoint / UserOperation plumbing has been removed. It is driven only + * by (a) approved DelegationManagers via `executeFromExecutor`, and (b) the account itself (the EIP-7702 EOA calling + * itself) via the self-gated methods. Signatures are validated via ERC-1271 (`isValidSignature`). + * @dev Namespaced storage is used so the approved-manager set never collides with other code sharing the EOA's storage under + * EIP-7702. + */ +abstract contract EIP7702MultiManagerDeleGatorCore is + ExecutionHelper, + IERC165, + IERC7821, + IDeleGatorCore, + IERC721Receiver, + IERC1155Receiver +{ + using ModeLib for ModeCode; + using ModeLib for ModeSelector; + using ExecutionLib for bytes; + + ////////////////////////////// State ////////////////////////////// + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + address private immutable __self = address(this); + + /// @dev The ERC-7201 namespaced storage location for the approved-manager set. + /// @dev keccak256(abi.encode(uint256(keccak256("DeleGator.EIP7702MultiManager")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant MULTI_MANAGER_STORAGE_LOCATION = 0x49e56a63dc56241c65d46138ca3c27c5bf7b4df245f96cb568e8e7ba7c940400; + + ////////////////////////////// Events ////////////////////////////// + + /// @dev Emitted when a DelegationManager is approved to drive this account + event ApprovedDelegationManager(IDelegationManager indexed delegationManager); + + /// @dev Emitted when a DelegationManager's approval is revoked + event RevokedDelegationManager(IDelegationManager indexed delegationManager); + + ////////////////////////////// Errors ////////////////////////////// + + /// @dev Error thrown when the caller is not this contract (self). + error NotSelf(); + + /// @dev Error thrown when the caller is not an approved delegation manager. + error NotDelegationManager(); + + /// @dev Error thrown when routing to a DelegationManager that is not approved. + error UnapprovedDelegationManager(); + + /// @dev The call is from an unauthorized context. + error UnauthorizedCallContext(); + + /// @dev Error thrown when an execution with an unsupported CallType was made. + error UnsupportedCallType(CallType callType); + + /// @dev Error thrown when an execution with an unsupported ExecType was made. + error UnsupportedExecType(ExecType execType); + + ////////////////////////////// Modifiers ////////////////////////////// + + /** + * @dev Prevents direct calls to the implementation. Under EIP-7702 the etched account runs in the EOA context, so + * `address(this) != __self` and the check passes. + */ + modifier onlyProxy() { + if (address(this) == __self) revert UnauthorizedCallContext(); + _; + } + + /** + * @notice Require the function call to come from the account itself (the EIP-7702 EOA calling itself). + */ + modifier onlySelf() { + if (msg.sender != address(this)) revert NotSelf(); + _; + } + + /** + * @notice Require the function call to come from an APPROVED DelegationManager. + * @dev Set-membership check against the EIP-7201 namespaced approved-manager set. + */ + modifier onlyDelegationManager() { + if (!_getMultiManagerStorage().isApprovedDelegationManager[msg.sender]) revert NotDelegationManager(); + _; + } + + ////////////////////////////// External Methods ////////////////////////////// + + /** + * @notice Allows this contract to receive the chain's native token. + */ + receive() external payable { } + + /** + * @notice Approves a DelegationManager to drive this account (grant it root access via `executeFromExecutor`). + * @dev MUST be called by the account itself. + * @param _delegationManager The DelegationManager to approve. + */ + function approveDelegationManager(IDelegationManager _delegationManager) external onlySelf { + _getMultiManagerStorage().isApprovedDelegationManager[address(_delegationManager)] = true; + emit ApprovedDelegationManager(_delegationManager); + } + + /** + * @notice Revokes a DelegationManager's approval. + * @dev MUST be called by the account itself. + * @param _delegationManager The DelegationManager to revoke. + */ + function revokeDelegationManager(IDelegationManager _delegationManager) external onlySelf { + delete _getMultiManagerStorage().isApprovedDelegationManager[address(_delegationManager)]; + emit RevokedDelegationManager(_delegationManager); + } + + /** + * @notice Returns whether a DelegationManager is approved to drive this account. + */ + function isApprovedDelegationManager(IDelegationManager _delegationManager) external view returns (bool) { + return _getMultiManagerStorage().isApprovedDelegationManager[address(_delegationManager)]; + } + + /** + * @notice Redeems delegations on a chosen APPROVED DelegationManager and executes on behalf of the root delegator. + * @param _delegationManager The approved DelegationManager to route through. + * @param _permissionContexts See {IDelegationManager.redeemDelegations}. + * @param _modes See {IDelegationManager.redeemDelegations}. + * @param _executionCallDatas See {IDelegationManager.redeemDelegations}. + */ + function redeemDelegations( + IDelegationManager _delegationManager, + bytes[] calldata _permissionContexts, + ModeCode[] calldata _modes, + bytes[] calldata _executionCallDatas + ) + external + onlySelf + { + if (!_getMultiManagerStorage().isApprovedDelegationManager[address(_delegationManager)]) { + revert UnapprovedDelegationManager(); + } + _delegationManager.redeemDelegations(_permissionContexts, _modes, _executionCallDatas); + } + + /** + * @notice Executes a single Execution from this contract. + * @param _execution The Execution to be executed + */ + function execute(Execution calldata _execution) external payable onlySelf { + _execute(_execution.target, _execution.value, _execution.callData); + } + + /** + * @notice Executes an Execution from this contract (ERC-7821 / ERC-7579 modes). + * @param _mode The ModeCode for the execution + * @param _executionCalldata The calldata for the execution + */ + function execute(ModeCode _mode, bytes calldata _executionCalldata) external payable onlySelf { + (CallType callType_, ExecType execType_,,) = _mode.decode(); + + if (callType_ == CALLTYPE_BATCH) { + Execution[] calldata executions_ = _executionCalldata.decodeBatch(); + if (execType_ == EXECTYPE_DEFAULT) _execute(executions_); + else if (execType_ == EXECTYPE_TRY) _tryExecute(executions_); + else revert UnsupportedExecType(execType_); + } else if (callType_ == CALLTYPE_SINGLE) { + (address target_, uint256 value_, bytes calldata callData_) = _executionCalldata.decodeSingle(); + if (execType_ == EXECTYPE_DEFAULT) { + _execute(target_, value_, callData_); + } else if (execType_ == EXECTYPE_TRY) { + bytes[] memory returnData_ = new bytes[](1); + bool success_; + (success_, returnData_[0]) = _tryExecute(target_, value_, callData_); + if (!success_) emit TryExecuteUnsuccessful(0, returnData_[0]); + } else { + revert UnsupportedExecType(execType_); + } + } else { + revert UnsupportedCallType(callType_); + } + } + + /** + * @inheritdoc IDeleGatorCore + * @dev Gated by the approved-manager set membership check. + */ + function executeFromExecutor( + ModeCode _mode, + bytes calldata _executionCalldata + ) + external + payable + onlyDelegationManager + returns (bytes[] memory returnData_) + { + (CallType callType_, ExecType execType_,,) = _mode.decode(); + + if (callType_ == CALLTYPE_BATCH) { + Execution[] calldata executions_ = _executionCalldata.decodeBatch(); + if (execType_ == EXECTYPE_DEFAULT) returnData_ = _execute(executions_); + else if (execType_ == EXECTYPE_TRY) returnData_ = _tryExecute(executions_); + else revert UnsupportedExecType(execType_); + } else if (callType_ == CALLTYPE_SINGLE) { + (address target_, uint256 value_, bytes calldata callData_) = _executionCalldata.decodeSingle(); + returnData_ = new bytes[](1); + bool success_; + if (execType_ == EXECTYPE_DEFAULT) { + returnData_[0] = _execute(target_, value_, callData_); + } else if (execType_ == EXECTYPE_TRY) { + (success_, returnData_[0]) = _tryExecute(target_, value_, callData_); + if (!success_) emit TryExecuteUnsuccessful(0, returnData_[0]); + } else { + revert UnsupportedExecType(execType_); + } + } else { + revert UnsupportedCallType(callType_); + } + } + + /** + * @inheritdoc IERC1271 + * @notice Verifies the signature of the signer. + */ + function isValidSignature( + bytes32 _hash, + bytes calldata _signature + ) + external + view + override + onlyProxy + returns (bytes4 magicValue_) + { + return _isValidSignature(_hash, _signature); + } + + /// @inheritdoc IERC721Receiver + function onERC721Received(address, address, uint256, bytes memory) external view override onlyProxy returns (bytes4) { + return this.onERC721Received.selector; + } + + /// @inheritdoc IERC1155Receiver + function onERC1155Received(address, address, uint256, uint256, bytes memory) external view override onlyProxy returns (bytes4) { + return this.onERC1155Received.selector; + } + + /// @inheritdoc IERC1155Receiver + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) + external + view + override + onlyProxy + returns (bytes4) + { + return this.onERC1155BatchReceived.selector; + } + + /** + * @notice Disables a delegation on a chosen approved DelegationManager. + * @param _delegationManager The approved DelegationManager that owns the delegation state. + * @param _delegation The delegation to be disabled + */ + function disableDelegation(IDelegationManager _delegationManager, Delegation calldata _delegation) external onlySelf { + if (!_getMultiManagerStorage().isApprovedDelegationManager[address(_delegationManager)]) { + revert UnapprovedDelegationManager(); + } + _delegationManager.disableDelegation(_delegation); + } + + /** + * @notice Checks if a delegation is disabled on a given DelegationManager. + */ + function isDelegationDisabled(IDelegationManager _delegationManager, bytes32 _delegationHash) external view returns (bool) { + return _delegationManager.disabledDelegations(_delegationHash); + } + + /** + * @notice Returns a boolean indicating if a mode is supported. + * @param _mode The mode to validate + */ + function supportsExecutionMode(ModeCode _mode) external view virtual override returns (bool) { + (CallType callType_, ExecType execType_, ModeSelector modeSelector_, ModePayload modePayload_) = _mode.decode(); + + return ((callType_ == CALLTYPE_SINGLE || callType_ == CALLTYPE_BATCH) + && (execType_ == EXECTYPE_DEFAULT || execType_ == EXECTYPE_TRY) && (modeSelector_ == MODE_DEFAULT) + && (ModePayload.unwrap(modePayload_) == bytes22(0x00))); + } + + /** + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 _interfaceId) public view virtual override(IERC165) onlyProxy returns (bool) { + return _interfaceId == type(IDeleGatorCore).interfaceId || _interfaceId == type(IERC721Receiver).interfaceId + || _interfaceId == type(IERC1155Receiver).interfaceId || _interfaceId == type(IERC165).interfaceId + || _interfaceId == type(IERC1271).interfaceId || _interfaceId == type(IERC7821).interfaceId; + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @notice Loads the ERC-7201 namespaced storage struct for the multi-manager account. + */ + function _getMultiManagerStorage() internal pure returns (EIP7702MultiManagerStorage storage s_) { + assembly { + s_.slot := MULTI_MANAGER_STORAGE_LOCATION + } + } + + /** + * @notice The logic to verify if the signature is valid for this contract. + * @dev Overridden by the implementing contract based on the signature scheme used. + */ + function _isValidSignature(bytes32 _hash, bytes calldata _signature) internal view virtual returns (bytes4); +} diff --git a/src/SimpleDelegationManager.sol b/src/SimpleDelegationManager.sol new file mode 100644 index 00000000..4f062d38 --- /dev/null +++ b/src/SimpleDelegationManager.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +import { ICaveatEnforcer } from "./interfaces/ICaveatEnforcer.sol"; +import { IDelegationManager } from "./interfaces/IDelegationManager.sol"; +import { IDeleGatorCore } from "./interfaces/IDeleGatorCore.sol"; +import { Delegation, Caveat, ModeCode } from "./utils/Types.sol"; +import { EncoderLib } from "./libraries/EncoderLib.sol"; +import { ERC1271Lib } from "./libraries/ERC1271Lib.sol"; +import { HookFlagsLib } from "./libraries/HookFlagsLib.sol"; + +/** + * @title SimpleDelegationManager + * @notice A gas-optimized, purpose-built DelegationManager + * + * @dev It keeps the canonical ERC-7710 `redeemDelegations(bytes[],ModeCode[],bytes[])` interface and the `Delegation`-struct + * signing model, validates the delegation chain leaf-to-root, and supports a one-way `disableDelegation` (revoke). It is + * deliberately leaner than the canonical `DelegationManager`: + * + * - No owner / pausing. There is no `Ownable`, no `Pausable`, no `pause`/`unpause`. + * - Revoke-only. `disableDelegation` permanently disables a delegation; there is NO `enableDelegation`. + * - No self-authorized redemption (the empty-permissionContext path is removed). + * - One combined validation+hook pass. Signature, disabled, authority/delegate-chain, and `beforeHook` are all done + * in a SINGLE leaf-to-root loop (vs the canonical manager's separate passes), then `executeFromExecutor`, then an + * OPTIONAL `afterHook` reverse pass (entered only if a caveat advertises it), then events. + * - No `beforeAllHook` / `afterAllHook`. Those batch-level phases are not run. + * - `beforeHook`/`afterHook` are called ONLY when the enforcer's address advertises the + * matching permission (see {HookFlagsLib}) + * + * @dev SECURITY ORDERING: because validation and `beforeHook` are fused into one pass, a delegation's `beforeHook` may run + * before a more-rootward delegation's signature/authority is validated. Redemption is atomic, so any later validation + * failure reverts the whole transaction (rolling back any hook side effects), preserving safety. `beforeHook` still runs + * leaf-to-root and `afterHook` root-to-leaf, and execution still happens only after the entire chain is validated and all + * `beforeHook`s have run. + * + * @dev !!! SECURITY WARNING !!! There is NO on-chain check that an enforcer's address flags match the hooks it + * actually implements. If a security-relevant enforcer (e.g. a BalanceChangeEnforcer whose check is in `afterHook`, or an + * ExactExecution / LimitedCalls enforcer whose check is in `beforeHook`) is deployed at an address LACKING the matching + * flag bit, this manager SILENTLY SKIPS that hook and the constraint is not enforced. + */ +contract SimpleDelegationManager is EIP712 { + using MessageHashUtils for bytes32; + + ////////////////////////////// State ////////////////////////////// + + /// @dev The name of the contract + string public constant NAME = "SimpleDelegationManager"; + + /// @dev The full version of the contract + string public constant VERSION = "1.3.0"; + + /// @dev The version used in the domainSeparator for EIP712 + string public constant DOMAIN_VERSION = "1"; + + /// @dev Special authority value. Indicates that the delegator is the authority + bytes32 public constant ROOT_AUTHORITY = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; + + /// @dev Special delegate value. Allows any delegate to redeem the delegation + address public constant ANY_DELEGATE = address(0xa11); + + /// @dev A mapping of delegation hashes that have been (permanently) disabled by the delegator + mapping(bytes32 delegationHash => bool isDisabled) public disabledDelegations; + + ////////////////////////////// Modifier ////////////////////////////// + + /** + * @notice Require the caller to be the delegator. + */ + modifier onlyDeleGator(address delegator) { + if (delegator != msg.sender) revert IDelegationManager.InvalidDelegator(); + _; + } + + ////////////////////////////// Constructor ////////////////////////////// + + /** + * @notice Initializes the SimpleDelegationManager's EIP-712 domain. + */ + constructor() EIP712(NAME, DOMAIN_VERSION) { + emit IDelegationManager.SetDomain(_domainSeparatorV4(), NAME, DOMAIN_VERSION, block.chainid, address(this)); + } + + ////////////////////////////// External Methods ////////////////////////////// + + /** + * @notice Permanently disables a delegation (revoke). Disabled delegations always fail upon redemption. + * @dev MUST be called by the delegator. There is no way to re-enable. + * @param _delegation The delegation to disable. + */ + function disableDelegation(Delegation calldata _delegation) external onlyDeleGator(_delegation.delegator) { + bytes32 delegationHash_ = getDelegationHash(_delegation); + if (disabledDelegations[delegationHash_]) revert IDelegationManager.AlreadyDisabled(); + disabledDelegations[delegationHash_] = true; + emit IDelegationManager.DisabledDelegation(delegationHash_, _delegation.delegator, _delegation.delegate, _delegation); + } + + /** + * @notice Validates permission contexts and executes the corresponding executions on behalf of each root delegator. + * @dev Same ERC-7710 interface as `DelegationManager.redeemDelegations`. + * @param _permissionContexts An array where each element is `abi.encode(Delegation[])` ordered leaf to root (NON-empty). + * @param _modes An array of execution modes, one per redemption. + * @param _executionCallDatas An array of encoded executions, one per redemption. + */ + function redeemDelegations( + bytes[] calldata _permissionContexts, + ModeCode[] calldata _modes, + bytes[] calldata _executionCallDatas + ) + external + { + uint256 batchSize_ = _permissionContexts.length; + if (batchSize_ != _executionCallDatas.length || batchSize_ != _modes.length) { + revert IDelegationManager.BatchDataLengthMismatch(); + } + + // Hoist the domain separator once for all redemptions. + bytes32 domainHash_ = _domainSeparatorV4(); + + for (uint256 batchIndex_; batchIndex_ < batchSize_; ++batchIndex_) { + _redeem(domainHash_, _permissionContexts[batchIndex_], _modes[batchIndex_], _executionCallDatas[batchIndex_]); + } + } + + /** + * @notice This method returns the domain hash used for signing typed data. + */ + function getDomainHash() public view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @notice Creates a hash of a Delegation. + */ + function getDelegationHash(Delegation calldata _input) public pure returns (bytes32) { + return EncoderLib._getDelegationHash(_input); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @notice Processes a single redemption: validate the chain + run beforeHook in one pass, execute, then optional afterHook. + * @param _domainHash The hoisted EIP-712 domain separator. + * @param _permissionContext `abi.encode(Delegation[])` ordered leaf to root (must be non-empty). + * @param _mode The execution mode. + * @param _executionCallData The encoded execution. + */ + function _redeem( + bytes32 _domainHash, + bytes calldata _permissionContext, + ModeCode _mode, + bytes calldata _executionCallData + ) + internal + { + Delegation[] memory delegations_ = abi.decode(_permissionContext, (Delegation[])); + uint256 length_ = delegations_.length; + + // The leaf delegate must be the caller (or the open ANY_DELEGATE sentinel). + if (delegations_[0].delegate != msg.sender && delegations_[0].delegate != ANY_DELEGATE) { + revert IDelegationManager.InvalidDelegate(); + } + + // Single combined leaf-to-root pass: hash + signature + disabled + authority/delegate chain + beforeHook. + // The authority/delegate link between delegation (i-1) and i is checked when i is reached: delegations_[i-1].authority + // must equal i's hash, mirroring the canonical manager's chain validation. + bool hasAfterHook_; + for (uint256 i; i < length_; ++i) { + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegations_[i]); + + // Signature validation (EOA via ECDSA, or contract/EIP-7702 via ERC-1271). + _validateSignature( + delegations_[i].delegator, MessageHashUtils.toTypedDataHash(_domainHash, delegationHash_), delegations_[i].signature + ); + + // Disabled check. + if (disabledDelegations[delegationHash_]) revert IDelegationManager.CannotUseADisabledDelegation(); + + // Authority + delegate chain: validate the (i-1) -> i link (delegations_[i-1].authority must equal i's hash). + if (i != 0) { + if (delegations_[i - 1].authority != delegationHash_) revert IDelegationManager.InvalidAuthority(); + address curDelegate_ = delegations_[i].delegate; + if (curDelegate_ != ANY_DELEGATE && delegations_[i - 1].delegator != curDelegate_) { + revert IDelegationManager.InvalidDelegate(); + } + } + + // beforeHook (flag-gated) for this delegation's caveats; note if any caveat also wants an afterHook. + if (_beforeHooksForDelegation(delegations_[i], delegationHash_, _mode, _executionCallData)) hasAfterHook_ = true; + } + + // Root authority: the most-rootward delegation must be self-authorized. + if (delegations_[length_ - 1].authority != ROOT_AUTHORITY) revert IDelegationManager.InvalidAuthority(); + + // Execute on the root delegator. + address rootDelegator_ = delegations_[length_ - 1].delegator; + IDeleGatorCore(rootDelegator_).executeFromExecutor(_mode, _executionCallData); + + // Optional afterHook (flag-gated, root to leaf) — entered only when a caveat advertised it. Hashes are recomputed + // here (rather than buffered) so the common gasless path pays for no hash array. + if (hasAfterHook_) { + for (uint256 i = length_; i > 0; --i) { + Delegation memory delegation_ = delegations_[i - 1]; + _afterHooksForDelegation(delegation_, EncoderLib._getDelegationHash(delegation_), _mode, _executionCallData); + } + } + + // Emit one RedeemedDelegation per delegation in the chain. + for (uint256 i; i < length_; ++i) { + emit IDelegationManager.RedeemedDelegation(rootDelegator_, msg.sender, delegations_[i]); + } + } + + /** + * @notice Validates a delegation signature: ECDSA for EOA delegators, ERC-1271 for contracts (incl. EIP-7702 accounts). + * @dev Isolated to keep the redemption loop's stack shallow. + */ + function _validateSignature(address _delegator, bytes32 _typedDataHash, bytes memory _signature) private view { + if (_delegator.code.length == 0) { + if (ECDSA.recover(_typedDataHash, _signature) != _delegator) revert IDelegationManager.InvalidEOASignature(); + } else { + if (IERC1271(_delegator).isValidSignature(_typedDataHash, _signature) != ERC1271Lib.EIP1271_MAGIC_VALUE) { + revert IDelegationManager.InvalidERC1271Signature(); + } + } + } + + /** + * @notice Runs the flag-gated `beforeHook` for every caveat in a single delegation. + * @dev Scoped to one delegation to keep the redemption loop's stack shallow. + * @return hasAfterHook_ True if any caveat in this delegation advertises {HookFlagsLib.AFTER_HOOK_FLAG}. + */ + function _beforeHooksForDelegation( + Delegation memory _delegation, + bytes32 _delegationHash, + ModeCode _mode, + bytes calldata _executionCallData + ) + private + returns (bool hasAfterHook_) + { + Caveat[] memory caveats_ = _delegation.caveats; + address delegator_ = _delegation.delegator; + for (uint256 c; c < caveats_.length; ++c) { + address enforcer_ = caveats_[c].enforcer; + if (HookFlagsLib.hasFlag(enforcer_, HookFlagsLib.BEFORE_HOOK_FLAG)) { + ICaveatEnforcer(enforcer_) + .beforeHook( + caveats_[c].terms, caveats_[c].args, _mode, _executionCallData, _delegationHash, delegator_, msg.sender + ); + } + if (HookFlagsLib.hasFlag(enforcer_, HookFlagsLib.AFTER_HOOK_FLAG)) hasAfterHook_ = true; + } + } + + /** + * @notice Runs the flag-gated `afterHook` for every caveat in a single delegation (reverse caveat order). + */ + function _afterHooksForDelegation( + Delegation memory _delegation, + bytes32 _delegationHash, + ModeCode _mode, + bytes calldata _executionCallData + ) + private + { + Caveat[] memory caveats_ = _delegation.caveats; + address delegator_ = _delegation.delegator; + for (uint256 c = caveats_.length; c > 0; --c) { + address enforcer_ = caveats_[c - 1].enforcer; + if (HookFlagsLib.hasFlag(enforcer_, HookFlagsLib.AFTER_HOOK_FLAG)) { + ICaveatEnforcer(enforcer_) + .afterHook( + caveats_[c - 1].terms, + caveats_[c - 1].args, + _mode, + _executionCallData, + _delegationHash, + delegator_, + msg.sender + ); + } + } + } +} diff --git a/src/libraries/HookFlagsLib.sol b/src/libraries/HookFlagsLib.sol new file mode 100644 index 00000000..a53a9e54 --- /dev/null +++ b/src/libraries/HookFlagsLib.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +/** + * @title HookFlagsLib + * @notice Uniswap-v4-style hook-permission flags encoded in the LOW NIBBLE (lowest 4 bits) of a caveat-enforcer address. + * @dev Inspired by Uniswap v4's `Hooks` library, which packs each callback's permission into a bit of the hook contract's + * address and gates every callback with a pure `uint160(addr) & FLAG != 0` test — so the core never makes an external + * call into a phase a hook didn't opt into. We apply the same idea to `CaveatEnforcer`'s four hook phases. + * + * An enforcer's hook permissions are therefore carried by its ADDRESS, which is part of the EIP-712-signed + * `Caveat.enforcer` (see `EncoderLib._getCaveatPacketHash`). The flags are thus committed by the delegator's signature + * and cannot be forged without breaking the delegation hash / chain-authority check — exactly v4's trust model. + * + * To deploy an enforcer at a flag-bearing address, mine a CREATE2 salt until the resulting address's low nibble equals + * the desired flag bits (see the comparison test's `_deployFlagged` helper). Enforcers that only implement `beforeHook` + * (e.g. ExactExecution*, LimitedCalls) want a low nibble of `BEFORE_HOOK_FLAG` (0x4). + */ +library HookFlagsLib { + /// @dev The enforcer implements `beforeAllHook`. + uint160 internal constant BEFORE_ALL_HOOK_FLAG = 1 << 3; // 0x08 + + /// @dev The enforcer implements `beforeHook`. + uint160 internal constant BEFORE_HOOK_FLAG = 1 << 2; // 0x04 + + /// @dev The enforcer implements `afterHook`. + uint160 internal constant AFTER_HOOK_FLAG = 1 << 1; // 0x02 + + /// @dev The enforcer implements `afterAllHook`. + uint160 internal constant AFTER_ALL_HOOK_FLAG = 1 << 0; // 0x01 + + /// @dev Mask covering all hook-permission bits (the low nibble). + uint160 internal constant HOOK_FLAG_MASK = (1 << 4) - 1; // 0x0f + + /** + * @notice Pure bit-test for a hook permission on an enforcer address — zero storage reads, zero external calls. + * @param _enforcer The caveat-enforcer address whose low-nibble encodes its hook permissions. + * @param _flag The hook-permission flag to test for. + * @return True if the enforcer's address has the given flag bit set. + */ + function hasFlag(address _enforcer, uint160 _flag) internal pure returns (bool) { + return uint160(_enforcer) & _flag != 0; + } +} diff --git a/test/OptimizedDelegationManagerBenchmark.t.sol b/test/OptimizedDelegationManagerBenchmark.t.sol new file mode 100644 index 00000000..3ee47cc7 --- /dev/null +++ b/test/OptimizedDelegationManagerBenchmark.t.sol @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { console } from "forge-std/console.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { BaseTest } from "./utils/BaseTest.t.sol"; +import { Implementation, SignatureType, TestUser } from "./utils/Types.t.sol"; +import { Execution, Caveat, Delegation, ModeCode } from "../src/utils/Types.sol"; +import { EncoderLib } from "../src/libraries/EncoderLib.sol"; +import { BasicERC20 } from "./utils/BasicERC20.t.sol"; +import { Counter } from "./utils/Counter.t.sol"; + +import { ExactExecutionEnforcer } from "../src/enforcers/ExactExecutionEnforcer.sol"; +import { ExactExecutionBatchEnforcer } from "../src/enforcers/ExactExecutionBatchEnforcer.sol"; +import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; + +/** + * @title Optimized DelegationManager Gas Benchmark + * + * @notice Establishes a gas baseline for `redeemDelegations` on an EIP-7702 account, as groundwork for a dedicated, gas-optimized + * "GaslessDelegationManager". The two gasless product flows Option 3 targets are benchmarked here against the CURRENT + * `DelegationManager` so that, once the optimized manager exists, we can measure the delta apples-to-apples. + * + * @dev The gasless flows: + * 1. Gasless transaction: a 2-execution batch (user action + ERC-20 fee transfer), gated by `ExactExecutionBatchEnforcer` + + * `LimitedCallsEnforcer`. + * 2. Gasless swap: a single execution, gated by `ExactExecutionEnforcer` + `LimitedCallsEnforcer`. + * + * @dev WHAT IS MEASURED: We measure ONLY the `redeemDelegations(...)` call, as a relayer would submit it (direct call to the + * manager, + * NOT a 4337 UserOp — the EntryPoint path would swamp the number). The EIP-7702 "upgrade" cost is EXCLUDED by construction: + * `BaseTest.deployDeleGator_EIP7702Stateless` installs the 7702 delegation designator (`0xef0100 || impl`) via `vm.etch` + * during `setUp()`. There is no type-4 authorization transaction in the measured region, so its gas never lands in these + * numbers. + * - For each scenario we report three numbers: + * * "execution gas" — gas consumed by the `redeemDelegations` call frame (the primary KPI to iterate against). + * * "calldata gas" — EIP-2028 cost (4 gas / zero byte, 16 / non-zero byte) of the redeem calldata. + * * "est. tx gas" — 21_000 intrinsic + calldata gas + execution gas: a realistic estimate of the standalone relayer + * transaction (still excluding any 7702 authorization). + * + * @dev MEASUREMENT NOTES + * - Gas is measured with the portable `gasleft()` bracket (forge-std v1.7.6 here has no `vm.startSnapshotGas` / + * `vm.lastCallGas`). Run with `-vv` to print the per-scenario report. + * - Each test redeems a fresh, single-use delegation (`LimitedCallsEnforcer` limit = 1), so the enforcer's counter SSTORE + * is a cold 0->1 write — the realistic one-shot gasless cost. Cross-address access (token, target, account) is also cold + * on first touch within the test body, approximating a standalone transaction. For strict per-call isolation use + * `forge test --isolate --match-contract OptimizedDelegationManagerBenchmark`. + * - Numbers are a baseline, not a regression gate; no upper-bound asserts (gas will move as the optimized manager lands). + * + * @dev HOW TO REPOINT AT THE FUTURE GaslessDelegationManager + * These tests call `delegationManager` (the current manager wired into the 7702 accounts by `BaseTest`). Once a + * `GaslessDelegationManager` exists and the 7702 account approves it, override `_manager()` (or swap the deployment in + * `setUp`) and re-run to get the optimized numbers side-by-side. + */ +contract OptimizedDelegationManagerBenchmark is BaseTest { + using MessageHashUtils for bytes32; + + ////////////////////// Configure BaseTest ////////////////////// + + constructor() { + IMPLEMENTATION = Implementation.EIP7702Stateless; + SIGNATURE_TYPE = SignatureType.EOA; + } + + ////////////////////////////// Constants ////////////////////////////// + + /// @dev Base intrinsic gas of an Ethereum transaction (excludes any EIP-7702 authorization-list cost). + uint256 internal constant INTRINSIC_GAS = 21_000; + + uint256 internal constant SWAP_AMOUNT = 100e18; // gasless swap: tokens moved by the "swap" + uint256 internal constant SEND_AMOUNT = 50e18; // gasless tx: user-intended transfer + uint256 internal constant FEE_AMOUNT = 1e18; // gasless tx: gas-payment leg to the MetaMask fee account + + ////////////////////////////// State ////////////////////////////// + + ExactExecutionEnforcer internal exactExecutionEnforcer; + ExactExecutionBatchEnforcer internal exactExecutionBatchEnforcer; + LimitedCallsEnforcer internal limitedCallsEnforcer; + + BasicERC20 internal token; // stand-in for USDC (swap proceeds / fee token) + Counter internal counter; // baseline target, owned by Alice's 7702 account + + address internal relayer; // the gasless relayer == leaf delegate == redeemer (msg.sender) + address internal recipient; // recipient of the user-intended action + address internal feeAccount; // MetaMask fee account (gasless-transaction fee leg) + + ////////////////////////////// Set Up ////////////////////////////// + + function setUp() public override { + super.setUp(); + + // Enforcers used by the two gasless flows. + exactExecutionEnforcer = new ExactExecutionEnforcer(); + exactExecutionBatchEnforcer = new ExactExecutionBatchEnforcer(); + limitedCallsEnforcer = new LimitedCallsEnforcer(); + vm.label(address(exactExecutionEnforcer), "ExactExecutionEnforcer"); + vm.label(address(exactExecutionBatchEnforcer), "ExactExecutionBatchEnforcer"); + vm.label(address(limitedCallsEnforcer), "LimitedCallsEnforcer"); + + // Token held by Alice's 7702 account (the delegator/root). + token = new BasicERC20(address(this), "Mock USDC", "USDC", 0); + token.mint(address(users.alice.deleGator), 1_000_000e18); + vm.label(address(token), "MockUSDC"); + + // Baseline target owned by Alice's account so executeFromExecutor passes its onlyOwner check. + counter = new Counter(address(users.alice.deleGator)); + vm.label(address(counter), "Counter"); + + relayer = makeAddr("Relayer"); + recipient = makeAddr("Recipient"); + feeAccount = makeAddr("MetaMaskFeeAccount"); + } + + /// @dev The manager under benchmark. Override / repoint when the GaslessDelegationManager exists. + function _manager() internal view returns (address) { + return address(delegationManager); + } + + ////////////////////////////// Benchmarks ////////////////////////////// + + /// @notice Floor cost: single execution, no caveats. Pure redeemDelegations + executeFromExecutor overhead. + function test_bench_baseline_singleNoCaveats() public { + Execution memory exec_ = + Execution({ target: address(counter), value: 0, callData: abi.encodeWithSelector(Counter.increment.selector) }); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = _signedRootDelegation(relayer, users.alice, new Caveat[](0)); + + uint256 before_ = counter.count(); + _benchRedeem( + "baseline | single execution | no caveats", + relayer, + delegations_, + ModeLib.encodeSimpleSingle(), + ExecutionLib.encodeSingle(exec_.target, exec_.value, exec_.callData) + ); + assertEq(counter.count(), before_ + 1, "counter should increment"); + } + + /// @notice Gasless swap shape: single execution gated by ExactExecutionEnforcer + LimitedCallsEnforcer. + /// @dev The swap is modeled as an ERC-20 transfer from Alice's account (stand-in for the swap-router call); the swap fee is + /// raised on the swap itself, so there is no separate fee leg (per ADR Option 3). + function test_bench_gaslessSwap_singleExecution() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + address target_ = address(token); + + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = _exactExecutionSingleCaveat(target_, 0, swapCallData_); + caveats_[1] = _limitedCallsCaveat(1); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = _signedRootDelegation(relayer, users.alice, caveats_); + + uint256 before_ = token.balanceOf(recipient); + _benchRedeem( + "gasless swap | single execution | ExactExecution + LimitedCalls", + relayer, + delegations_, + ModeLib.encodeSimpleSingle(), + ExecutionLib.encodeSingle(target_, 0, swapCallData_) + ); + assertEq(token.balanceOf(recipient), before_ + SWAP_AMOUNT, "swap proceeds should reach recipient"); + } + + /// @notice Gasless transaction shape: 2-execution batch (user action + ERC-20 gas-fee transfer) gated by + /// ExactExecutionBatchEnforcer + LimitedCallsEnforcer. + function test_bench_gaslessTransaction_batchTwoExecutions() public { + Execution[] memory executions_ = new Execution[](2); + executions_[0] = Execution({ + target: address(token), value: 0, callData: abi.encodeWithSelector(IERC20.transfer.selector, recipient, SEND_AMOUNT) + }); + executions_[1] = Execution({ + target: address(token), value: 0, callData: abi.encodeWithSelector(IERC20.transfer.selector, feeAccount, FEE_AMOUNT) + }); + + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = _exactExecutionBatchCaveat(executions_); + caveats_[1] = _limitedCallsCaveat(1); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = _signedRootDelegation(relayer, users.alice, caveats_); + + uint256 recipientBefore_ = token.balanceOf(recipient); + uint256 feeBefore_ = token.balanceOf(feeAccount); + _benchRedeem( + "gasless transaction | 2-exec batch | ExactExecutionBatch + LimitedCalls", + relayer, + delegations_, + ModeLib.encodeSimpleBatch(), + ExecutionLib.encodeBatch(executions_) + ); + assertEq(token.balanceOf(recipient), recipientBefore_ + SEND_AMOUNT, "user action should transfer to recipient"); + assertEq(token.balanceOf(feeAccount), feeBefore_ + FEE_AMOUNT, "fee leg should transfer to fee account"); + } + + /// @notice Gasless swap over a 2-link delegation chain (root: Alice->Bob, leaf: Bob->relayer). Measures the leaf-to-root + /// validation cost (2 signatures, 2 authority checks, 2 hook sets, 2 events) that Option 3 explicitly preserves. + function test_bench_gaslessSwap_chainedDelegation() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + address target_ = address(token); + + // Root delegation: Alice (7702 account, the funds-holder) delegates full authority to Bob. + Delegation memory root_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: new Caveat[](0), + salt: 0, + signature: hex"" + }); + root_ = signDelegation(users.alice, root_); + + // Leaf delegation: Bob delegates to the relayer, scoped to the exact swap + one-shot replay protection. + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = _exactExecutionSingleCaveat(target_, 0, swapCallData_); + caveats_[1] = _limitedCallsCaveat(1); + Delegation memory leaf_ = Delegation({ + delegate: relayer, + delegator: address(users.bob.deleGator), + authority: EncoderLib._getDelegationHash(root_), + caveats: caveats_, + salt: 0, + signature: hex"" + }); + leaf_ = signDelegation(users.bob, leaf_); + + // Ordered leaf -> root. + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + + uint256 before_ = token.balanceOf(recipient); + _benchRedeem( + "gasless swap | chained (root Alice->Bob, leaf Bob->relayer) | ExactExecution + LimitedCalls", + relayer, + delegations_, + ModeLib.encodeSimpleSingle(), + ExecutionLib.encodeSingle(target_, 0, swapCallData_) + ); + assertEq(token.balanceOf(recipient), before_ + SWAP_AMOUNT, "chained swap proceeds should reach recipient"); + } + + /// @notice Decomposition variant: gasless swap with ExactExecution only (no LimitedCalls). Isolates the cold SSTORE cost of + /// the replay counter so the future GaslessDelegationManager comparison can separate manager overhead from caveat + /// state writes. NOTE: not a production shape — it has no replay protection. + function test_bench_gaslessSwap_exactExecutionOnly() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + address target_ = address(token); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = _exactExecutionSingleCaveat(target_, 0, swapCallData_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = _signedRootDelegation(relayer, users.alice, caveats_); + + uint256 before_ = token.balanceOf(recipient); + _benchRedeem( + "gasless swap | single execution | ExactExecution only (no LimitedCalls)", + relayer, + delegations_, + ModeLib.encodeSimpleSingle(), + ExecutionLib.encodeSingle(target_, 0, swapCallData_) + ); + assertEq(token.balanceOf(recipient), before_ + SWAP_AMOUNT, "swap proceeds should reach recipient"); + } + + ////////////////////////////// Internal: builders ////////////////////////////// + + /// @dev Builds a single root delegation (authority = ROOT_AUTHORITY) from `signer`'s 7702 account to `delegate`, signed by + /// `signer`. For an EIP-7702 account the delegator address equals the EOA address, and the DelegationManager validates + /// the signature via ERC-1271 (the account has code), recovering to the EOA key. + function _signedRootDelegation( + address delegate, + TestUser memory signer, + Caveat[] memory caveats + ) + internal + view + returns (Delegation memory delegation_) + { + delegation_ = Delegation({ + delegate: delegate, + delegator: address(signer.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats, + salt: 0, + signature: hex"" + }); + delegation_ = signDelegation(signer, delegation_); + } + + function _exactExecutionSingleCaveat( + address target, + uint256 value, + bytes memory callData + ) + internal + view + returns (Caveat memory) + { + // ExactExecutionEnforcer terms MUST be byte-identical to the single execution calldata (packed encoding). + return Caveat({ + enforcer: address(exactExecutionEnforcer), terms: ExecutionLib.encodeSingle(target, value, callData), args: hex"" + }); + } + + function _exactExecutionBatchCaveat(Execution[] memory executions) internal view returns (Caveat memory) { + // ExactExecutionBatchEnforcer terms MUST be the batch encoding (abi.encode(Execution[])) of the same executions. + return Caveat({ enforcer: address(exactExecutionBatchEnforcer), terms: ExecutionLib.encodeBatch(executions), args: hex"" }); + } + + function _limitedCallsCaveat(uint256 limit) internal view returns (Caveat memory) { + // LimitedCallsEnforcer terms = abi.encode(uint256 limit) (exactly 32 bytes). + return Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(limit), args: hex"" }); + } + + ////////////////////////////// Internal: measurement ////////////////////////////// + + /** + * @dev Measures ONLY the `redeemDelegations` call and logs the gas report. All calldata is pre-encoded before the + * `gasleft()` bracket so struct/array ABI encoding is excluded from the measured region. The call is made via a + * pre-encoded low-level call (calldata is bytes already), so the bracket captures the call dispatch + full execution + * — closely matching the on-chain `redeemDelegations` frame. + */ + function _benchRedeem( + string memory label, + address redeemer, + Delegation[] memory delegations, + ModeCode mode, + bytes memory executionCallData + ) + internal + { + bytes memory redeemCalldata_ = _encodeRedeem(delegations, mode, executionCallData); + + vm.prank(redeemer); + uint256 gasBefore_ = gasleft(); + (bool ok_, bytes memory ret_) = _manager().call(redeemCalldata_); + uint256 executionGas_ = gasBefore_ - gasleft(); + + if (!ok_) { + // Bubble up the revert reason so a misconfigured benchmark fails loudly. + assembly { + revert(add(ret_, 0x20), mload(ret_)) + } + } + + _report(label, executionGas_, redeemCalldata_); + } + + /// @dev Encodes the single-redemption `redeemDelegations` calldata exactly as a relayer would submit it. + function _encodeRedeem( + Delegation[] memory delegations, + ModeCode mode, + bytes memory executionCallData + ) + internal + view + returns (bytes memory) + { + bytes[] memory permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(delegations); + + ModeCode[] memory modes_ = new ModeCode[](1); + modes_[0] = mode; + + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = executionCallData; + + return + abi.encodeWithSelector(delegationManager.redeemDelegations.selector, permissionContexts_, modes_, executionCallDatas_); + } + + /// @dev Logs the three-number gas report for one scenario. Uses string.concat + vm.toString so a single console.log(string) + /// overload renders every line reliably under `-vv`. + function _report(string memory label, uint256 executionGas, bytes memory redeemCalldata) internal view { + uint256 calldataGas_ = _calldataGas(redeemCalldata); + uint256 estTxGas_ = INTRINSIC_GAS + calldataGas_ + executionGas; + + console.log("====================================================================="); + console.log(label); + console.log(string.concat(" redeemDelegations execution gas .... ", vm.toString(executionGas))); + console.log(string.concat(" calldata size (bytes) .............. ", vm.toString(redeemCalldata.length))); + console.log(string.concat(" calldata gas (EIP-2028) ............ ", vm.toString(calldataGas_))); + console.log(string.concat(" est. standalone tx gas (excl. 7702) ", vm.toString(estTxGas_))); + console.log("====================================================================="); + } + + /// @dev EIP-2028 calldata cost: 4 gas per zero byte, 16 gas per non-zero byte (London rules; matches foundry.toml). + function _calldataGas(bytes memory data) internal pure returns (uint256 gas_) { + uint256 len_ = data.length; + for (uint256 i; i < len_; ++i) { + gas_ += data[i] == 0x00 ? 4 : 16; + } + } +} diff --git a/test/SimpleDelegationManagerComparison.t.sol b/test/SimpleDelegationManagerComparison.t.sol new file mode 100644 index 00000000..ff24bb8b --- /dev/null +++ b/test/SimpleDelegationManagerComparison.t.sol @@ -0,0 +1,611 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { console } from "forge-std/console.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { BaseTest } from "./utils/BaseTest.t.sol"; +import { Implementation, SignatureType, TestUser } from "./utils/Types.t.sol"; +import { Execution, Caveat, Delegation, ModeCode } from "../src/utils/Types.sol"; +import { EncoderLib } from "../src/libraries/EncoderLib.sol"; +import { HookFlagsLib } from "../src/libraries/HookFlagsLib.sol"; +import { SigningUtilsLib } from "./utils/SigningUtilsLib.t.sol"; +import { StorageUtilsLib } from "./utils/StorageUtilsLib.t.sol"; +import { BasicERC20 } from "./utils/BasicERC20.t.sol"; +import { Counter } from "./utils/Counter.t.sol"; + +import { IDelegationManager } from "../src/interfaces/IDelegationManager.sol"; +import { DelegationManager } from "../src/DelegationManager.sol"; +import { SimpleDelegationManager } from "../src/SimpleDelegationManager.sol"; +import { EIP7702MultiManagerDeleGator } from "../src/EIP7702/EIP7702MultiManagerDeleGator.sol"; +import { EIP7702MultiManagerDeleGatorCore } from "../src/EIP7702/EIP7702MultiManagerDeleGatorCore.sol"; +import { ExactExecutionEnforcer } from "../src/enforcers/ExactExecutionEnforcer.sol"; +import { ExactExecutionBatchEnforcer } from "../src/enforcers/ExactExecutionBatchEnforcer.sol"; +import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; + +/** + * @title SimpleDelegationManager vs DelegationManager — side-by-side gas comparison + * + * @notice Benchmarks `redeemDelegations` gas for the gasless flows on an EIP-7702 account, comparing the canonical + * `DelegationManager` against the gas-optimized `SimpleDelegationManager` (ADR #0002 Option 3). + * + * @dev SETUP that makes the comparison FAIR (only the manager logic differs): + * - The delegator is a NEW {EIP7702MultiManagerDeleGator} 7702 account that approves BOTH managers (an EIP-7201 + * approved-manager set replaces the single immutable manager), so the SAME account can be driven by either. + * - Both managers redeem the SAME caveats against the SAME enforcer instances. Those enforcers are deployed at + * CREATE2-mined, flag-bearing addresses (low nibble = {HookFlagsLib.BEFORE_HOOK_FLAG}), so `SimpleDelegationManager` + * can skip the no-op hook phases via a pure address-bit test (Uniswap-v4-style), while `DelegationManager` ignores + * the bits and calls all four phases — exactly the overhead being measured. + * - Each manager is measured from an IDENTICAL cold state via `vm.snapshot()` / `vm.revertTo()`, so warm-storage + * ordering does not bias the result. + * - The 7702 "upgrade" is installed with `vm.etch` in setUp, so its gas is excluded (see + * OptimizedDelegationManagerBenchmark.t.sol for the rationale). Gas is measured with the portable `gasleft()` bracket. + * + * @dev Run with `-vv` to print the comparison. Use `--isolate` for cold per-call absolute numbers. + */ +contract SimpleDelegationManagerComparison is BaseTest { + using MessageHashUtils for bytes32; + + ////////////////////// Configure BaseTest ////////////////////// + + constructor() { + IMPLEMENTATION = Implementation.EIP7702Stateless; + SIGNATURE_TYPE = SignatureType.EOA; + } + + ////////////////////////////// Constants ////////////////////////////// + + uint256 internal constant INTRINSIC_GAS = 21_000; + uint256 internal constant SWAP_AMOUNT = 100e18; + uint256 internal constant SEND_AMOUNT = 50e18; + uint256 internal constant FEE_AMOUNT = 1e18; + + /// @dev keccak256(abi.encode(uint256(keccak256("DeleGator.EIP7702MultiManager")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 internal constant EXPECTED_MULTI_MANAGER_SLOT = 0x49e56a63dc56241c65d46138ca3c27c5bf7b4df245f96cb568e8e7ba7c940400; + + ////////////////////////////// State ////////////////////////////// + + DelegationManager internal currentManager; // the canonical manager (from BaseTest) + SimpleDelegationManager internal simpleManager; // the gas-optimized manager + EIP7702MultiManagerDeleGator internal multiManagerImpl; // the new multi-manager 7702 account implementation + + ExactExecutionEnforcer internal exactExecutionEnforcer; + ExactExecutionBatchEnforcer internal exactExecutionBatchEnforcer; + LimitedCallsEnforcer internal limitedCallsEnforcer; + + BasicERC20 internal token; + Counter internal counter; + + address internal relayer; + address internal recipient; + address internal feeAccount; + + ////////////////////////////// Set Up ////////////////////////////// + + function setUp() public override { + super.setUp(); + + currentManager = delegationManager; // BaseTest's canonical DelegationManager + simpleManager = new SimpleDelegationManager(); + vm.label(address(simpleManager), "SimpleDelegationManager"); + + // New multi-manager 7702 account implementation; re-etch Alice (root/executor) and Bob (chain intermediary) onto it. + multiManagerImpl = new EIP7702MultiManagerDeleGator(); + vm.label(address(multiManagerImpl), "EIP7702MultiManager Impl"); + _etchMultiManager(users.alice.addr); + _etchMultiManager(users.bob.addr); + + // Alice (the funds-holding root delegator) approves both managers so either can drive her account. + EIP7702MultiManagerDeleGator aliceAccount_ = EIP7702MultiManagerDeleGator(payable(users.alice.addr)); + vm.startPrank(users.alice.addr); + aliceAccount_.approveDelegationManager(IDelegationManager(address(currentManager))); + aliceAccount_.approveDelegationManager(IDelegationManager(address(simpleManager))); + vm.stopPrank(); + + // Deploy the three gasless enforcers at flag-bearing addresses (BEFORE_HOOK_FLAG only) via CREATE2 salt mining. + exactExecutionEnforcer = ExactExecutionEnforcer(_deployFlagged(type(ExactExecutionEnforcer).creationCode)); + exactExecutionBatchEnforcer = ExactExecutionBatchEnforcer(_deployFlagged(type(ExactExecutionBatchEnforcer).creationCode)); + limitedCallsEnforcer = LimitedCallsEnforcer(_deployFlagged(type(LimitedCallsEnforcer).creationCode)); + vm.label(address(exactExecutionEnforcer), "ExactExecutionEnforcer(flagged)"); + vm.label(address(exactExecutionBatchEnforcer), "ExactExecutionBatchEnforcer(flagged)"); + vm.label(address(limitedCallsEnforcer), "LimitedCallsEnforcer(flagged)"); + + token = new BasicERC20(address(this), "Mock USDC", "USDC", 0); + token.mint(users.alice.addr, 1_000_000e18); + vm.label(address(token), "MockUSDC"); + + counter = new Counter(users.alice.addr); + vm.label(address(counter), "Counter"); + + relayer = makeAddr("Relayer"); + recipient = makeAddr("Recipient"); + feeAccount = makeAddr("MetaMaskFeeAccount"); + } + + ////////////////////////////// Sanity checks ////////////////////////////// + + /// @notice The hardcoded EIP-7201 slot matches the namespace formula. + function test_multiManager_storageSlotMatchesNamespace() public { + assertEq( + StorageUtilsLib.getStorageLocation("DeleGator.EIP7702MultiManager"), + EXPECTED_MULTI_MANAGER_SLOT, + "ERC-7201 slot mismatch" + ); + } + + /// @notice Each gasless enforcer is deployed at an address advertising BEFORE_HOOK_FLAG (and nothing else). + function test_enforcers_haveBeforeHookFlagOnly() public { + _assertBeforeHookOnly(address(exactExecutionEnforcer)); + _assertBeforeHookOnly(address(exactExecutionBatchEnforcer)); + _assertBeforeHookOnly(address(limitedCallsEnforcer)); + } + + /// @notice The multi-manager account rejects an unapproved manager trying to drive it. + function test_multiManager_rejectsUnapprovedManager() public { + SimpleDelegationManager rogue_ = new SimpleDelegationManager(); + + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Caveat[] memory caveats_ = _gaslessSwapCaveats(swapCallData_); + Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, caveats_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = _signDelegationFor(IDelegationManager(address(rogue_)), users.alice, unsigned_); + + bytes[] memory pc_ = new bytes[](1); + pc_[0] = abi.encode(delegations_); + ModeCode[] memory modes_ = new ModeCode[](1); + modes_[0] = ModeLib.encodeSimpleSingle(); + bytes[] memory ecd_ = new bytes[](1); + ecd_[0] = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); + + vm.prank(relayer); + vm.expectRevert(EIP7702MultiManagerDeleGatorCore.NotDelegationManager.selector); + rogue_.redeemDelegations(pc_, modes_, ecd_); + } + + ////////////////////////////// SimpleDelegationManager functional / negative paths ////////////////////////////// + + /// @notice Happy path: a single gasless-swap delegation redeems successfully through SimpleDelegationManager. + function test_simple_gaslessSwap_succeeds() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Delegation[] memory delegations_ = _signedGaslessSwap(relayer, swapCallData_); + + _redeemSimple( + relayer, delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_) + ); + assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "swap proceeds reached recipient"); + } + + /// @notice ANY_DELEGATE lets any account redeem. + function test_simple_anyDelegate_allowsAnyRedeemer() public { + address randomRedeemer_ = makeAddr("RandomRedeemer"); + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Delegation[] memory delegations_ = _signedGaslessSwap(ANY_DELEGATE, swapCallData_); + + _redeemSimple( + randomRedeemer_, delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_) + ); + assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "ANY_DELEGATE redemption succeeded"); + } + + /// @notice A redeemer that is not the leaf delegate is rejected. + function test_simple_revertsOnWrongDelegate() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Delegation[] memory delegations_ = _signedGaslessSwap(relayer, swapCallData_); + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = + _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + + vm.prank(makeAddr("NotTheDelegate")); + vm.expectRevert(IDelegationManager.InvalidDelegate.selector); + simpleManager.redeemDelegations(pc_, modes_, ecd_); + } + + /// @notice A broken authority link in a chain is rejected (exercises the combined-loop chain validation). + function test_simple_revertsOnBrokenAuthorityChain() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + + Delegation memory root_ = _signDelegationFor( + IDelegationManager(address(simpleManager)), + users.alice, + _rootDelegation(address(users.bob.addr), users.alice.addr, new Caveat[](0)) + ); + // Leaf points to a WRONG authority (not the root's hash) -> InvalidAuthority. + Delegation memory leaf_ = _signDelegationFor( + IDelegationManager(address(simpleManager)), + users.bob, + Delegation({ + delegate: relayer, + delegator: users.bob.addr, + authority: keccak256("not-the-root-hash"), + caveats: _gaslessSwapCaveats(swapCallData_), + salt: 0, + signature: hex"" + }) + ); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = + _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + + vm.prank(relayer); + vm.expectRevert(IDelegationManager.InvalidAuthority.selector); + simpleManager.redeemDelegations(pc_, modes_, ecd_); + } + + /// @notice A tampered/foreign signature is rejected (ERC-1271 path for the 7702 account). + function test_simple_revertsOnInvalidSignature() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, _gaslessSwapCaveats(swapCallData_)); + // Sign with Bob's key for an Alice-delegator delegation -> recovers to Bob != Alice -> ERC1271 fails. + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = _signDelegationFor(IDelegationManager(address(simpleManager)), users.bob, unsigned_); + + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = + _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + + vm.prank(relayer); + vm.expectRevert(IDelegationManager.InvalidERC1271Signature.selector); + simpleManager.redeemDelegations(pc_, modes_, ecd_); + } + + /// @notice A disabled delegation cannot be redeemed. + function test_simple_revertsOnDisabledDelegation() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Delegation[] memory delegations_ = _signedGaslessSwap(relayer, swapCallData_); + + // The delegator (Alice's account) disables the delegation on the manager. + vm.prank(users.alice.addr); + simpleManager.disableDelegation(delegations_[0]); + + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = + _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + + vm.prank(relayer); + vm.expectRevert(IDelegationManager.CannotUseADisabledDelegation.selector); + simpleManager.redeemDelegations(pc_, modes_, ecd_); + } + + /// @notice LimitedCallsEnforcer (limit = 1) blocks a second redemption of the same delegation. + function test_simple_limitedCalls_blocksReplay() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Delegation[] memory delegations_ = _signedGaslessSwap(relayer, swapCallData_); + bytes memory execData_ = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); + + _redeemSimple(relayer, delegations_, ModeLib.encodeSimpleSingle(), execData_); + + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = + _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), execData_); + vm.prank(relayer); + vm.expectRevert(bytes("LimitedCallsEnforcer:limit-exceeded")); + simpleManager.redeemDelegations(pc_, modes_, ecd_); + } + + ////////////////////////////// Comparisons ////////////////////////////// + + /// @notice Baseline: single execution, no caveats. Pure manager + executeFromExecutor overhead. + function test_compare_baseline_singleNoCaveats() public { + Execution memory exec_ = + Execution({ target: address(counter), value: 0, callData: abi.encodeWithSelector(Counter.increment.selector) }); + bytes memory execData_ = ExecutionLib.encodeSingle(exec_.target, exec_.value, exec_.callData); + + Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, new Caveat[](0)); + _runComparison("baseline | single execution | no caveats", unsigned_, users.alice, ModeLib.encodeSimpleSingle(), execData_); + assertEq(counter.count(), 1, "counter incremented exactly once (post-revert state)"); + } + + /// @notice Gasless swap: single execution, ExactExecution + LimitedCalls. + function test_compare_gaslessSwap_singleExecution() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + bytes memory execData_ = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); + + Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, _gaslessSwapCaveats(swapCallData_)); + _runComparison( + "gasless swap | single execution | ExactExecution + LimitedCalls", + unsigned_, + users.alice, + ModeLib.encodeSimpleSingle(), + execData_ + ); + assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "swap proceeds reached recipient"); + } + + /// @notice Gasless transaction: 2-execution batch (user transfer + fee transfer), ExactExecutionBatch + LimitedCalls. + function test_compare_gaslessTransaction_batchTwoExecutions() public { + Execution[] memory executions_ = new Execution[](2); + executions_[0] = Execution({ + target: address(token), value: 0, callData: abi.encodeWithSelector(IERC20.transfer.selector, recipient, SEND_AMOUNT) + }); + executions_[1] = Execution({ + target: address(token), value: 0, callData: abi.encodeWithSelector(IERC20.transfer.selector, feeAccount, FEE_AMOUNT) + }); + bytes memory execData_ = ExecutionLib.encodeBatch(executions_); + + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = + Caveat({ enforcer: address(exactExecutionBatchEnforcer), terms: ExecutionLib.encodeBatch(executions_), args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + + Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, caveats_); + _runComparison( + "gasless transaction | 2-exec batch | ExactExecutionBatch + LimitedCalls", + unsigned_, + users.alice, + ModeLib.encodeSimpleBatch(), + execData_ + ); + assertEq(token.balanceOf(recipient), SEND_AMOUNT, "user action reached recipient"); + assertEq(token.balanceOf(feeAccount), FEE_AMOUNT, "fee leg reached fee account"); + } + + /// @notice Gasless swap over a 2-link chain (root Alice->Bob, leaf Bob->relayer): measures leaf-to-root validation cost. + function test_compare_gaslessSwap_chainedDelegation() public { + bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + bytes memory execData_ = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); + ModeCode mode_ = ModeLib.encodeSimpleSingle(); + + // Build the unsigned chain once; sign per-manager domain inside the comparison. + Delegation memory rootUnsigned_ = _rootDelegation(address(users.bob.addr), users.alice.addr, new Caveat[](0)); + Delegation memory leafUnsigned_ = Delegation({ + delegate: relayer, + delegator: users.bob.addr, + authority: bytes32(0), // set after the root is signed (authority excludes signature, so hash is stable) + caveats: _gaslessSwapCaveats(swapCallData_), + salt: 0, + signature: hex"" + }); + + bytes memory cd_; + uint256 currentGas_; + uint256 simpleGas_; + + uint256 snap_ = vm.snapshot(); + (currentGas_, cd_) = + _measureChained(IDelegationManager(address(currentManager)), rootUnsigned_, leafUnsigned_, mode_, execData_); + assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "chained swap (current) reached recipient"); + vm.revertTo(snap_); + (simpleGas_,) = _measureChained(IDelegationManager(address(simpleManager)), rootUnsigned_, leafUnsigned_, mode_, execData_); + assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "chained swap (simple) reached recipient"); + + _report("gasless swap | chained (root Alice->Bob, leaf Bob->relayer)", currentGas_, simpleGas_, cd_); + assertLt(simpleGas_, currentGas_, "SimpleDelegationManager should cost less than DelegationManager"); + } + + ////////////////////////////// Internal: comparison drivers ////////////////////////////// + + /// @dev Signs `_unsigned` for each manager's domain and measures both from an identical cold state, then reports. + function _runComparison( + string memory _label, + Delegation memory _unsigned, + TestUser memory _signer, + ModeCode _mode, + bytes memory _execData + ) + internal + { + Delegation[] memory forCurrent_ = new Delegation[](1); + forCurrent_[0] = _signDelegationFor(IDelegationManager(address(currentManager)), _signer, _unsigned); + Delegation[] memory forSimple_ = new Delegation[](1); + forSimple_[0] = _signDelegationFor(IDelegationManager(address(simpleManager)), _signer, _unsigned); + + uint256 snap_ = vm.snapshot(); + (uint256 currentGas_, bytes memory cd_) = + _measureRedeem(IDelegationManager(address(currentManager)), relayer, forCurrent_, _mode, _execData); + // Capture the canonical manager's observable effect, then revert and run the simple manager from the same cold state. + (uint256 curRecipient_, uint256 curFee_, uint256 curCounter_) = + (token.balanceOf(recipient), token.balanceOf(feeAccount), counter.count()); + + vm.revertTo(snap_); + (uint256 simpleGas_,) = _measureRedeem(IDelegationManager(address(simpleManager)), relayer, forSimple_, _mode, _execData); + + // Functional equivalence: both managers must produce byte-identical observable effects on the tracked state. + assertEq(token.balanceOf(recipient), curRecipient_, "recipient effect must match across managers"); + assertEq(token.balanceOf(feeAccount), curFee_, "fee effect must match across managers"); + assertEq(counter.count(), curCounter_, "counter effect must match across managers"); + + _report(_label, currentGas_, simpleGas_, cd_); + assertLt(simpleGas_, currentGas_, "SimpleDelegationManager should cost less than DelegationManager"); + } + + /// @dev Signs and measures a 2-link chain (root signed by Alice, leaf signed by Bob) for a given manager. + function _measureChained( + IDelegationManager _manager, + Delegation memory _rootUnsigned, + Delegation memory _leafUnsigned, + ModeCode _mode, + bytes memory _execData + ) + internal + returns (uint256 executionGas_, bytes memory cd_) + { + Delegation memory root_ = _signDelegationFor(_manager, users.alice, _rootUnsigned); + _leafUnsigned.authority = EncoderLib._getDelegationHash(root_); + Delegation memory leaf_ = _signDelegationFor(_manager, users.bob, _leafUnsigned); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + + return _measureRedeem(_manager, relayer, delegations_, _mode, _execData); + } + + /// @dev Measures ONLY the redeemDelegations call (gasleft bracket, pre-encoded calldata, low-level call from `redeemer`). + function _measureRedeem( + IDelegationManager _manager, + address _redeemer, + Delegation[] memory _delegations, + ModeCode _mode, + bytes memory _execData + ) + internal + returns (uint256 executionGas_, bytes memory cd_) + { + bytes[] memory pc_ = new bytes[](1); + pc_[0] = abi.encode(_delegations); + ModeCode[] memory modes_ = new ModeCode[](1); + modes_[0] = _mode; + bytes[] memory ecd_ = new bytes[](1); + ecd_[0] = _execData; + + cd_ = abi.encodeWithSelector(IDelegationManager.redeemDelegations.selector, pc_, modes_, ecd_); + + vm.prank(_redeemer); + uint256 gasBefore_ = gasleft(); + (bool ok_, bytes memory ret_) = address(_manager).call(cd_); + executionGas_ = gasBefore_ - gasleft(); + if (!ok_) { + assembly { + revert(add(ret_, 0x20), mload(ret_)) + } + } + } + + /// @dev Prints the side-by-side gas report for one scenario. + function _report(string memory _label, uint256 _currentGas, uint256 _simpleGas, bytes memory _cd) internal view { + uint256 calldataGas_ = _calldataGas(_cd); + uint256 saved_ = _currentGas > _simpleGas ? _currentGas - _simpleGas : 0; + uint256 pctBps_ = _currentGas > 0 ? (saved_ * 10_000) / _currentGas : 0; + + console.log("====================================================================="); + console.log(_label); + console.log(string.concat(" DelegationManager exec gas .. ", vm.toString(_currentGas))); + console.log(string.concat(" SimpleDelegationManager exec gas .. ", vm.toString(_simpleGas))); + console.log(string.concat(" saved (exec gas) .................. ", vm.toString(saved_))); + console.log(string.concat(" saved (%, basis points) ........... ", vm.toString(pctBps_))); + console.log(string.concat(" calldata gas (identical) .......... ", vm.toString(calldataGas_))); + console.log( + string.concat( + " est. tx gas current / simple ..... ", + vm.toString(INTRINSIC_GAS + calldataGas_ + _currentGas), + " / ", + vm.toString(INTRINSIC_GAS + calldataGas_ + _simpleGas) + ) + ); + console.log("====================================================================="); + } + + ////////////////////////////// Internal: builders ////////////////////////////// + + /// @dev Builds + signs (for SimpleDelegationManager's domain) a single gasless-swap root delegation. + function _signedGaslessSwap( + address _delegate, + bytes memory _swapCallData + ) + internal + view + returns (Delegation[] memory delegations_) + { + Delegation memory unsigned_ = _rootDelegation(_delegate, users.alice.addr, _gaslessSwapCaveats(_swapCallData)); + delegations_ = new Delegation[](1); + delegations_[0] = _signDelegationFor(IDelegationManager(address(simpleManager)), users.alice, unsigned_); + } + + /// @dev Redeems through SimpleDelegationManager (no gas measurement); reverts bubble to the test. + function _redeemSimple(address _redeemer, Delegation[] memory _delegations, ModeCode _mode, bytes memory _execData) internal { + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = _redeemArgs(_delegations, _mode, _execData); + vm.prank(_redeemer); + simpleManager.redeemDelegations(pc_, modes_, ecd_); + } + + /// @dev Packs the parallel arrays for a single redemption. + function _redeemArgs( + Delegation[] memory _delegations, + ModeCode _mode, + bytes memory _execData + ) + internal + pure + returns (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) + { + pc_ = new bytes[](1); + pc_[0] = abi.encode(_delegations); + modes_ = new ModeCode[](1); + modes_[0] = _mode; + ecd_ = new bytes[](1); + ecd_[0] = _execData; + } + + function _gaslessSwapCaveats(bytes memory _swapCallData) internal view returns (Caveat[] memory caveats_) { + caveats_ = new Caveat[](2); + caveats_[0] = Caveat({ + enforcer: address(exactExecutionEnforcer), + terms: ExecutionLib.encodeSingle(address(token), 0, _swapCallData), + args: hex"" + }); + caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + } + + function _rootDelegation( + address _delegate, + address _delegator, + Caveat[] memory _caveats + ) + internal + view + returns (Delegation memory) + { + return Delegation({ + delegate: _delegate, delegator: _delegator, authority: ROOT_AUTHORITY, caveats: _caveats, salt: 0, signature: hex"" + }); + } + + /// @dev Signs a delegation against a specific manager's EIP-712 domain (verifyingContract = manager). + function _signDelegationFor( + IDelegationManager _manager, + TestUser memory _signer, + Delegation memory _delegation + ) + internal + view + returns (Delegation memory signed_) + { + bytes32 delegationHash_ = EncoderLib._getDelegationHash(_delegation); + bytes32 typedDataHash_ = MessageHashUtils.toTypedDataHash(_manager.getDomainHash(), delegationHash_); + // Construct a fresh struct (do NOT alias `_delegation`, or signing twice would overwrite the shared signature field). + signed_ = Delegation({ + delegate: _delegation.delegate, + delegator: _delegation.delegator, + authority: _delegation.authority, + caveats: _delegation.caveats, + salt: _delegation.salt, + signature: SigningUtilsLib.signHash_EOA(_signer.privateKey, typedDataHash_) + }); + } + + ////////////////////////////// Internal: helpers ////////////////////////////// + + /// @dev Installs the EIP-7702 delegation designator (0xef0100 || impl) onto an EOA — the upgrade, excluded from measurement. + function _etchMultiManager(address _eoa) internal { + vm.etch(_eoa, bytes.concat(hex"ef0100", abi.encodePacked(address(multiManagerImpl)))); + } + + /// @dev Mines a CREATE2 salt until the deployed address's low nibble equals BEFORE_HOOK_FLAG, then deploys via SimpleFactory. + function _deployFlagged(bytes memory _creationCode) internal returns (address addr_) { + bytes32 codeHash_ = keccak256(_creationCode); + for (uint256 salt_;; ++salt_) { + address predicted_ = simpleFactory.computeAddress(codeHash_, bytes32(salt_)); + if (uint160(predicted_) & HookFlagsLib.HOOK_FLAG_MASK == HookFlagsLib.BEFORE_HOOK_FLAG) { + return simpleFactory.deploy(_creationCode, bytes32(salt_)); + } + } + } + + function _assertBeforeHookOnly(address _enforcer) internal { + assertTrue(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.BEFORE_HOOK_FLAG), "missing BEFORE_HOOK_FLAG"); + assertFalse(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.AFTER_HOOK_FLAG), "unexpected AFTER_HOOK_FLAG"); + assertFalse(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.BEFORE_ALL_HOOK_FLAG), "unexpected BEFORE_ALL_HOOK_FLAG"); + assertFalse(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.AFTER_ALL_HOOK_FLAG), "unexpected AFTER_ALL_HOOK_FLAG"); + } + + /// @dev EIP-2028 calldata cost: 4 gas per zero byte, 16 per non-zero byte. + function _calldataGas(bytes memory _data) internal pure returns (uint256 gas_) { + uint256 len_ = _data.length; + for (uint256 i; i < len_; ++i) { + gas_ += _data[i] == 0x00 ? 4 : 16; + } + } +} From 53dd5cb46103b5e10f763e7015af7833f686eb3f Mon Sep 17 00:00:00 2001 From: Ryan <81343914+McOso@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:35:40 -0400 Subject: [PATCH 2/2] feat: further optimize --- src/EIP7702/EIP7702BatchDeleGator.sol | 306 ++++++++++++++++++ src/SimpleDelegationManager.sol | 144 ++++----- src/enforcers/ExactExecutionBatchEnforcer.sol | 21 +- ...xactExecutionBatchLimitedCallsEnforcer.sol | 82 +++++ src/enforcers/ExactExecutionEnforcer.sol | 11 +- src/interfaces/IEIP7702BatchDeleGator.sol | 59 ++++ src/libraries/BatchAuthorizationLib.sol | 111 +++++++ src/libraries/HookFlagsLib.sol | 44 --- test/Erc7821BaselineBenchmark.t.sol | 136 ++++++++ test/SimpleDelegationManagerComparison.t.sol | 168 ++++------ 10 files changed, 832 insertions(+), 250 deletions(-) create mode 100644 src/EIP7702/EIP7702BatchDeleGator.sol create mode 100644 src/enforcers/ExactExecutionBatchLimitedCallsEnforcer.sol create mode 100644 src/interfaces/IEIP7702BatchDeleGator.sol create mode 100644 src/libraries/BatchAuthorizationLib.sol delete mode 100644 src/libraries/HookFlagsLib.sol create mode 100644 test/Erc7821BaselineBenchmark.t.sol diff --git a/src/EIP7702/EIP7702BatchDeleGator.sol b/src/EIP7702/EIP7702BatchDeleGator.sol new file mode 100644 index 00000000..604c510f --- /dev/null +++ b/src/EIP7702/EIP7702BatchDeleGator.sol @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { IEntryPoint } from "@account-abstraction/interfaces/IEntryPoint.sol"; +import { Execution } from "@erc7579/interfaces/IERC7579Account.sol"; + +import { EIP7702DeleGatorCore } from "./EIP7702DeleGatorCore.sol"; +import { IDelegationManager } from "../interfaces/IDelegationManager.sol"; +import { IEIP7702BatchDeleGator } from "../interfaces/IEIP7702BatchDeleGator.sol"; +import { ERC1271Lib } from "../libraries/ERC1271Lib.sol"; +import { BatchAuthorizationLib } from "../libraries/BatchAuthorizationLib.sol"; + +/** + * @title EIP7702BatchDeleGator + * @notice Stateful EIP-7702 DeleGator with ERC-7821 signed relay batches and unordered nonce replay protection. + * @dev Standard ERC-7579 execution remains on inherited `execute(ModeCode,bytes)` with EntryPoint/self access control. + * @dev Signed relay batches use the child-only `executeBatch(bytes32,bytes)` entrypoint. + */ +contract EIP7702BatchDeleGator is EIP7702DeleGatorCore, IEIP7702BatchDeleGator { + using BatchAuthorizationLib for Execution[]; + + ////////////////////////////// Constants ////////////////////////////// + + /// @dev The name of the contract used in the EIP-712 domain. + string public constant NAME = "EIP7702BatchDeleGator"; + + /// @dev The version used in the domainSeparator for EIP712. + string public constant DOMAIN_VERSION = "1"; + + /// @dev The semantic version of the contract. + string public constant VERSION = "1.0.0"; + + /// @dev Single batch, revert on failure — `abi.encode(Execution[])` only. + bytes32 public constant MODE_BATCH_SIMPLE = + bytes32(uint256(0x0100000000000000000000000000000000000000000000000000000000000000)); + + /// @dev Single batch with optional `opData` — `abi.encode(Execution[], bytes)`. + bytes32 public constant MODE_BATCH_WITH_OPDATA = + bytes32(uint256(0x0100000000007821000100000000000000000000000000000000000000000000)); + + /// @dev Nested signed batches — `abi.encode(bytes[])`. + bytes32 public constant MODE_BATCH_OF_BATCHES = + bytes32(uint256(0x0100000000007821000200000000000000000000000000000000000000000000)); + + /// @custom:storage-location erc7201:DeleGator.EIP7702BatchDeleGator.nonce + bytes32 private constant NONCE_STORAGE_LOCATION = + 0x1093877edb0cc0e2b2ea60a70fdf07c1dd8a109e13f7d461cf4b95c014189900; + + ////////////////////////////// Storage ////////////////////////////// + + struct NonceStorage { + /// @dev Bitmap of used relay nonces. Nonce word is `nonce >> 8`; bit is `uint8(nonce)`. + mapping(uint256 word => uint256 bitmap) nonceBitmap; + } + + ////////////////////////////// Events ////////////////////////////// + + event NonceInvalidated(uint256 indexed nonce); + event NoncesInvalidated(uint256 indexed word, uint256 mask); + + ////////////////////////////// Errors ////////////////////////////// + + error UnsupportedBatchExecutionMode(); + error UnauthorizedBatchExecuteCaller(); + error UnauthorizedRelayer(); + error InvalidBatchSignature(); + error BatchAuthorizationExpired(); + error NonceAlreadyUsed(); + + ////////////////////////////// Constructor ////////////////////////////// + + /** + * @notice Constructor for the EIP7702Batch DeleGator. + * @param _delegationManager Address of the trusted DelegationManager contract. + * @param _entryPoint Address of the EntryPoint contract. + */ + constructor(IDelegationManager _delegationManager, IEntryPoint _entryPoint) + EIP7702DeleGatorCore(_delegationManager, _entryPoint, NAME, DOMAIN_VERSION) + { } + + ////////////////////////////// External Methods ////////////////////////////// + + /// @inheritdoc IEIP7702BatchDeleGator + function executeBatch(bytes32 mode, bytes calldata executionData) external payable { + _routeBatchCalldata(mode, executionData); + } + + /// @inheritdoc IEIP7702BatchDeleGator + function supportsBatchExecutionMode(bytes32 mode) external pure returns (bool) { + return _batchExecutionModeId(mode) != 0; + } + + /// @inheritdoc IEIP7702BatchDeleGator + function hashBatchAuthorizationWithNonce( + Execution[] calldata executions, + uint256 nonce, + uint256 deadline, + address relayer + ) + external + view + returns (bytes32) + { + return _hashBatchAuthorizationWithNonce(executions, nonce, deadline, relayer); + } + + /// @inheritdoc IEIP7702BatchDeleGator + function isNonceUsed(uint256 nonce) external view returns (bool) { + (uint256 word, uint256 mask) = _nonceWordAndMask(nonce); + return _nonceStorage().nonceBitmap[word] & mask != 0; + } + + /// @inheritdoc IEIP7702BatchDeleGator + function nonceBitmap(uint256 word) external view returns (uint256 bitmap) { + return _nonceStorage().nonceBitmap[word]; + } + + /// @inheritdoc IEIP7702BatchDeleGator + function invalidateNonce(uint256 nonce) external onlyEntryPointOrSelf { + _consumeNonce(nonce); + emit NonceInvalidated(nonce); + } + + /// @inheritdoc IEIP7702BatchDeleGator + function invalidateNonces(uint256 word, uint256 mask) external onlyEntryPointOrSelf { + _nonceStorage().nonceBitmap[word] |= mask; + emit NoncesInvalidated(word, mask); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @notice Verifies relay signatures against the delegated EOA address. + * @param _hash The data signed. + * @param _signature A 65-byte signature produced by the EIP7702 EOA. + */ + function _isValidSignature(bytes32 _hash, bytes calldata _signature) internal view override returns (bytes4) { + if (ECDSA.recover(_hash, _signature) == address(this)) return ERC1271Lib.EIP1271_MAGIC_VALUE; + + return ERC1271Lib.SIG_VALIDATION_FAILED; + } + + /// @dev Mode id: 0 invalid, 1 simple batch, 2 batch + optional opData, 3 batch-of-batches. + function _batchExecutionModeId(bytes32 mode) internal pure returns (uint256 id) { + /// @solidity memory-safe-assembly + assembly { + let m := and(shr(mul(22, 8), mode), 0xffff00000000ffffffff) + id := eq(m, 0x01000000000000000000) + id := or(shl(1, eq(m, 0x01000000000078210001)), id) + id := or(mul(3, eq(m, 0x01000000000078210002)), id) + } + } + + function _routeBatchCalldata(bytes32 mode, bytes calldata executionData) internal { + uint256 id = _batchExecutionModeId(mode); + if (id == 0) revert UnsupportedBatchExecutionMode(); + + if (id == 3) { + mode ^= bytes32(uint256(3 << (22 * 8))); + bytes[] memory batches = abi.decode(executionData, (bytes[])); + uint256 n = batches.length; + for (uint256 i = 0; i < n;) { + _routeBatchMemory(mode, batches[i]); + unchecked { + ++i; + } + } + return; + } + + Execution[] memory executions; + bytes memory opData; + + if (id == 2) { + (executions, opData) = abi.decode(executionData, (Execution[], bytes)); + } else { + executions = abi.decode(executionData, (Execution[])); + opData = ""; + } + + _authorizeAndExecuteBatch(executions, opData); + } + + function _routeBatchMemory(bytes32 mode, bytes memory executionData) internal { + uint256 id = _batchExecutionModeId(mode); + if (id == 0) revert UnsupportedBatchExecutionMode(); + + if (id == 3) { + mode ^= bytes32(uint256(3 << (22 * 8))); + bytes[] memory batches = abi.decode(executionData, (bytes[])); + uint256 n = batches.length; + for (uint256 i = 0; i < n;) { + _routeBatchMemory(mode, batches[i]); + unchecked { + ++i; + } + } + return; + } + + Execution[] memory executions; + bytes memory opData; + + if (id == 2) { + (executions, opData) = abi.decode(executionData, (Execution[], bytes)); + } else { + executions = abi.decode(executionData, (Execution[])); + opData = ""; + } + + _authorizeAndExecuteBatch(executions, opData); + } + + function _authorizeAndExecuteBatch(Execution[] memory executions, bytes memory opData) internal { + if (opData.length != 0) { + _verifyBatchAuthorization(executions, opData); + } else if (msg.sender != address(this)) { + revert UnauthorizedBatchExecuteCaller(); + } + + _executeExecutions(executions); + } + + function _verifyBatchAuthorization(Execution[] memory executions, bytes memory opData) internal { + (uint256 nonce, uint256 deadline, address relayer, bytes memory signature) = + abi.decode(opData, (uint256, uint256, address, bytes)); + + if (block.timestamp > deadline) revert BatchAuthorizationExpired(); + if (relayer != address(0) && relayer != msg.sender) revert UnauthorizedRelayer(); + + (uint256 word, uint256 mask) = _nonceWordAndMask(nonce); + NonceStorage storage $ = _nonceStorage(); + uint256 bitmap = $.nonceBitmap[word]; + if (bitmap & mask != 0) revert NonceAlreadyUsed(); + + bytes32 callsDigest = BatchAuthorizationLib.executionsDigest(executions); + bytes32 structHash = BatchAuthorizationLib.batchAuthorizationWithNonceStructHash(callsDigest, nonce, deadline, relayer); + bytes32 digest = _hashTypedDataV4(structHash); + + address recovered = ECDSA.recover(digest, signature); + if (recovered != address(this)) revert InvalidBatchSignature(); + + $.nonceBitmap[word] = bitmap | mask; + } + + function _hashBatchAuthorizationWithNonce( + Execution[] calldata executions, + uint256 nonce, + uint256 deadline, + address relayer + ) + internal + view + returns (bytes32) + { + bytes32 callsDigest = BatchAuthorizationLib.executionsDigestCalldata(executions); + bytes32 structHash = BatchAuthorizationLib.batchAuthorizationWithNonceStructHash(callsDigest, nonce, deadline, relayer); + return _hashTypedDataV4(structHash); + } + + function _executeExecutions(Execution[] memory executions) internal { + uint256 n = executions.length; + for (uint256 i = 0; i < n;) { + Execution memory execution = executions[i]; + address target = execution.target == address(0) ? address(this) : execution.target; + bytes memory callData = execution.callData; + bool ok; + + /// @solidity memory-safe-assembly + assembly { + ok := call(gas(), target, mload(add(execution, 0x20)), add(callData, 0x20), mload(callData), 0, 0) + if iszero(ok) { + let ptr := mload(0x40) + let size := returndatasize() + returndatacopy(ptr, 0, size) + revert(ptr, size) + } + } + + unchecked { + ++i; + } + } + } + + function _nonceWordAndMask(uint256 nonce) internal pure returns (uint256 word, uint256 mask) { + word = nonce >> 8; + mask = 1 << uint8(nonce); + } + + function _consumeNonce(uint256 nonce) internal { + (uint256 word, uint256 mask) = _nonceWordAndMask(nonce); + NonceStorage storage $ = _nonceStorage(); + uint256 bitmap = $.nonceBitmap[word]; + if (bitmap & mask != 0) revert NonceAlreadyUsed(); + $.nonceBitmap[word] = bitmap | mask; + } + + function _nonceStorage() private pure returns (NonceStorage storage $) { + /// @solidity memory-safe-assembly + assembly { + $.slot := NONCE_STORAGE_LOCATION + } + } +} diff --git a/src/SimpleDelegationManager.sol b/src/SimpleDelegationManager.sol index 4f062d38..20d4b456 100644 --- a/src/SimpleDelegationManager.sol +++ b/src/SimpleDelegationManager.sol @@ -12,36 +12,29 @@ import { IDeleGatorCore } from "./interfaces/IDeleGatorCore.sol"; import { Delegation, Caveat, ModeCode } from "./utils/Types.sol"; import { EncoderLib } from "./libraries/EncoderLib.sol"; import { ERC1271Lib } from "./libraries/ERC1271Lib.sol"; -import { HookFlagsLib } from "./libraries/HookFlagsLib.sol"; /** * @title SimpleDelegationManager - * @notice A gas-optimized, purpose-built DelegationManager + * @notice A gas-optimized, purpose-built DelegationManager for gasless flows (ADR #0002 Option 3). * * @dev It keeps the canonical ERC-7710 `redeemDelegations(bytes[],ModeCode[],bytes[])` interface and the `Delegation`-struct * signing model, validates the delegation chain leaf-to-root, and supports a one-way `disableDelegation` (revoke). It is * deliberately leaner than the canonical `DelegationManager`: * - * - No owner / pausing. There is no `Ownable`, no `Pausable`, no `pause`/`unpause`. - * - Revoke-only. `disableDelegation` permanently disables a delegation; there is NO `enableDelegation`. + * - No owner / pausing (no `Ownable`, no `Pausable`). + * - Revoke-only: `disableDelegation` permanently disables a delegation; there is NO `enableDelegation`. * - No self-authorized redemption (the empty-permissionContext path is removed). - * - One combined validation+hook pass. Signature, disabled, authority/delegate-chain, and `beforeHook` are all done - * in a SINGLE leaf-to-root loop (vs the canonical manager's separate passes), then `executeFromExecutor`, then an - * OPTIONAL `afterHook` reverse pass (entered only if a caveat advertises it), then events. - * - No `beforeAllHook` / `afterAllHook`. Those batch-level phases are not run. - * - `beforeHook`/`afterHook` are called ONLY when the enforcer's address advertises the - * matching permission (see {HookFlagsLib}) + * - `beforeHook` ONLY. The canonical manager runs four hook phases (`beforeAllHook`, `beforeHook`, `afterHook`, + * `afterAllHook`) over every caveat; this manager runs ONLY `beforeHook`, which is all the gasless caveats + * (`ExactExecution*`, replay/limit) implement. Enforcers that rely on the other phases are NOT supported here. + * - One combined validation+hook pass: signature, disabled, authority/delegate-chain, and `beforeHook` are all done in a + * SINGLE leaf-to-root loop (vs the canonical manager's separate passes), then `executeFromExecutor`, then events. + * - Inline-ECDSA signature fast path (see {_validateSignature}). * * @dev SECURITY ORDERING: because validation and `beforeHook` are fused into one pass, a delegation's `beforeHook` may run * before a more-rootward delegation's signature/authority is validated. Redemption is atomic, so any later validation * failure reverts the whole transaction (rolling back any hook side effects), preserving safety. `beforeHook` still runs - * leaf-to-root and `afterHook` root-to-leaf, and execution still happens only after the entire chain is validated and all - * `beforeHook`s have run. - * - * @dev !!! SECURITY WARNING !!! There is NO on-chain check that an enforcer's address flags match the hooks it - * actually implements. If a security-relevant enforcer (e.g. a BalanceChangeEnforcer whose check is in `afterHook`, or an - * ExactExecution / LimitedCalls enforcer whose check is in `beforeHook`) is deployed at an address LACKING the matching - * flag bit, this manager SILENTLY SKIPS that hook and the constraint is not enforced. + * leaf-to-root, and execution still happens only after the entire chain is validated and every `beforeHook` has run. */ contract SimpleDelegationManager is EIP712 { using MessageHashUtils for bytes32; @@ -66,6 +59,13 @@ contract SimpleDelegationManager is EIP712 { /// @dev A mapping of delegation hashes that have been (permanently) disabled by the delegator mapping(bytes32 delegationHash => bool isDisabled) public disabledDelegations; + ////////////////////////////// Events ////////////////////////////// + + /// @dev Emitted per delegation when redeemed. Leaner than `IDelegationManager.RedeemedDelegation`, which carries the full + /// `Delegation` struct in log data: this emits only the (fully indexed) delegation hash, which uniquely identifies it, + /// saving the struct ABI-encoding. The full delegation is recoverable from the signed payload off-chain. + event RedeemedDelegation(address indexed rootDelegator, address indexed redeemer, bytes32 indexed delegationHash); + ////////////////////////////// Modifier ////////////////////////////// /** @@ -143,7 +143,7 @@ contract SimpleDelegationManager is EIP712 { ////////////////////////////// Internal Methods ////////////////////////////// /** - * @notice Processes a single redemption: validate the chain + run beforeHook in one pass, execute, then optional afterHook. + * @notice Processes a single redemption: validate the chain + run beforeHook in one pass, execute, then emit. * @param _domainHash The hoisted EIP-712 domain separator. * @param _permissionContext `abi.encode(Delegation[])` ordered leaf to root (must be non-empty). * @param _mode The execution mode. @@ -165,14 +165,16 @@ contract SimpleDelegationManager is EIP712 { revert IDelegationManager.InvalidDelegate(); } - // Single combined leaf-to-root pass: hash + signature + disabled + authority/delegate chain + beforeHook. + // The root delegator (most-rootward) is the account executed on, and the `rootDelegator` of every emitted event. + address rootDelegator_ = delegations_[length_ - 1].delegator; + + // Single combined leaf-to-root pass: hash + signature + disabled + authority/delegate chain + beforeHook + emit. // The authority/delegate link between delegation (i-1) and i is checked when i is reached: delegations_[i-1].authority // must equal i's hash, mirroring the canonical manager's chain validation. - bool hasAfterHook_; for (uint256 i; i < length_; ++i) { bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegations_[i]); - // Signature validation (EOA via ECDSA, or contract/EIP-7702 via ERC-1271). + // Signature validation (inline ECDSA fast path, ERC-1271 fallback). _validateSignature( delegations_[i].delegator, MessageHashUtils.toTypedDataHash(_domainHash, delegationHash_), delegations_[i].signature ); @@ -180,110 +182,72 @@ contract SimpleDelegationManager is EIP712 { // Disabled check. if (disabledDelegations[delegationHash_]) revert IDelegationManager.CannotUseADisabledDelegation(); - // Authority + delegate chain: validate the (i-1) -> i link (delegations_[i-1].authority must equal i's hash). + // Authority + delegate chain. if (i != 0) { if (delegations_[i - 1].authority != delegationHash_) revert IDelegationManager.InvalidAuthority(); - address curDelegate_ = delegations_[i].delegate; - if (curDelegate_ != ANY_DELEGATE && delegations_[i - 1].delegator != curDelegate_) { + address nextDelegate_ = delegations_[i].delegate; + if (nextDelegate_ != ANY_DELEGATE && delegations_[i - 1].delegator != nextDelegate_) { revert IDelegationManager.InvalidDelegate(); } } - // beforeHook (flag-gated) for this delegation's caveats; note if any caveat also wants an afterHook. - if (_beforeHooksForDelegation(delegations_[i], delegationHash_, _mode, _executionCallData)) hasAfterHook_ = true; + // beforeHook for every caveat in this delegation (this manager runs no other hook phase). + _runBeforeHooks(delegations_[i], delegationHash_, _mode, _executionCallData); + + // Emit the (lean, hash-only) redemption event. The hash is already in hand here, so no second loop / re-hash; on any + // later revert (incl. the root-authority check or the execution) the whole tx rolls back, so no spurious event + // persists. + emit RedeemedDelegation(rootDelegator_, msg.sender, delegationHash_); } // Root authority: the most-rootward delegation must be self-authorized. if (delegations_[length_ - 1].authority != ROOT_AUTHORITY) revert IDelegationManager.InvalidAuthority(); // Execute on the root delegator. - address rootDelegator_ = delegations_[length_ - 1].delegator; IDeleGatorCore(rootDelegator_).executeFromExecutor(_mode, _executionCallData); - - // Optional afterHook (flag-gated, root to leaf) — entered only when a caveat advertised it. Hashes are recomputed - // here (rather than buffered) so the common gasless path pays for no hash array. - if (hasAfterHook_) { - for (uint256 i = length_; i > 0; --i) { - Delegation memory delegation_ = delegations_[i - 1]; - _afterHooksForDelegation(delegation_, EncoderLib._getDelegationHash(delegation_), _mode, _executionCallData); - } - } - - // Emit one RedeemedDelegation per delegation in the chain. - for (uint256 i; i < length_; ++i) { - emit IDelegationManager.RedeemedDelegation(rootDelegator_, msg.sender, delegations_[i]); - } } /** - * @notice Validates a delegation signature: ECDSA for EOA delegators, ERC-1271 for contracts (incl. EIP-7702 accounts). + * @notice Validates a delegation signature. + * @dev Optimized for the gasless target: a FAST PATH does an inline ECDSA recover-to-delegator (covers plain EOAs AND + * EIP-7702 accounts that use the EOA recover-to-self scheme) with NO external ERC-1271 call — this is the dominant + * saving vs the canonical manager, which always pays an external `isValidSignature` call for code-bearing accounts. + * Only if the inline recovery does not match does it fall back to ERC-1271 (preserving support for contract accounts + * with other signature schemes, e.g. multisig). The fast path is purely an optimization: it can never accept an + * invalid signature (`tryRecover` never reverts and a non-matching recovery falls through), it just avoids the external + * call in the common case. * @dev Isolated to keep the redemption loop's stack shallow. */ function _validateSignature(address _delegator, bytes32 _typedDataHash, bytes memory _signature) private view { - if (_delegator.code.length == 0) { - if (ECDSA.recover(_typedDataHash, _signature) != _delegator) revert IDelegationManager.InvalidEOASignature(); - } else { - if (IERC1271(_delegator).isValidSignature(_typedDataHash, _signature) != ERC1271Lib.EIP1271_MAGIC_VALUE) { - revert IDelegationManager.InvalidERC1271Signature(); - } + (address recovered_, ECDSA.RecoverError err_,) = ECDSA.tryRecover(_typedDataHash, _signature); + if (err_ == ECDSA.RecoverError.NoError && recovered_ == _delegator) return; + + // A codeless delegator can only be an EOA, so a non-matching recovery is conclusively invalid. + if (_delegator.code.length == 0) revert IDelegationManager.InvalidEOASignature(); + + // Fallback: contract delegator with a non-ECDSA scheme — validate via ERC-1271. + if (IERC1271(_delegator).isValidSignature(_typedDataHash, _signature) != ERC1271Lib.EIP1271_MAGIC_VALUE) { + revert IDelegationManager.InvalidERC1271Signature(); } } /** - * @notice Runs the flag-gated `beforeHook` for every caveat in a single delegation. + * @notice Runs `beforeHook` for every caveat in a single delegation (this manager runs no other hook phase). * @dev Scoped to one delegation to keep the redemption loop's stack shallow. - * @return hasAfterHook_ True if any caveat in this delegation advertises {HookFlagsLib.AFTER_HOOK_FLAG}. */ - function _beforeHooksForDelegation( + function _runBeforeHooks( Delegation memory _delegation, bytes32 _delegationHash, ModeCode _mode, bytes calldata _executionCallData ) private - returns (bool hasAfterHook_) { Caveat[] memory caveats_ = _delegation.caveats; address delegator_ = _delegation.delegator; for (uint256 c; c < caveats_.length; ++c) { - address enforcer_ = caveats_[c].enforcer; - if (HookFlagsLib.hasFlag(enforcer_, HookFlagsLib.BEFORE_HOOK_FLAG)) { - ICaveatEnforcer(enforcer_) - .beforeHook( - caveats_[c].terms, caveats_[c].args, _mode, _executionCallData, _delegationHash, delegator_, msg.sender - ); - } - if (HookFlagsLib.hasFlag(enforcer_, HookFlagsLib.AFTER_HOOK_FLAG)) hasAfterHook_ = true; - } - } - - /** - * @notice Runs the flag-gated `afterHook` for every caveat in a single delegation (reverse caveat order). - */ - function _afterHooksForDelegation( - Delegation memory _delegation, - bytes32 _delegationHash, - ModeCode _mode, - bytes calldata _executionCallData - ) - private - { - Caveat[] memory caveats_ = _delegation.caveats; - address delegator_ = _delegation.delegator; - for (uint256 c = caveats_.length; c > 0; --c) { - address enforcer_ = caveats_[c - 1].enforcer; - if (HookFlagsLib.hasFlag(enforcer_, HookFlagsLib.AFTER_HOOK_FLAG)) { - ICaveatEnforcer(enforcer_) - .afterHook( - caveats_[c - 1].terms, - caveats_[c - 1].args, - _mode, - _executionCallData, - _delegationHash, - delegator_, - msg.sender - ); - } + ICaveatEnforcer(caveats_[c].enforcer) + .beforeHook(caveats_[c].terms, caveats_[c].args, _mode, _executionCallData, _delegationHash, delegator_, msg.sender); } } } diff --git a/src/enforcers/ExactExecutionBatchEnforcer.sol b/src/enforcers/ExactExecutionBatchEnforcer.sol index 3ea6ee83..69dd027a 100644 --- a/src/enforcers/ExactExecutionBatchEnforcer.sol +++ b/src/enforcers/ExactExecutionBatchEnforcer.sol @@ -39,17 +39,22 @@ contract ExactExecutionBatchEnforcer is CaveatEnforcer { onlyBatchCallTypeMode(_mode) onlyDefaultExecutionMode(_mode) { + _validateBatch(_terms, _executionCallData); + } + + /** + * @notice Validates that the batch execution matches the expected batch exactly. + * @dev `_terms` and `_executionCallData` are both `ExecutionLib.encodeBatch(Execution[])` (= `abi.encode(Execution[])`), so + * byte equality is exactly batch equality. Decode only to surface the dedicated batch-size error; compare content via a + * single keccak over the raw calldata (avoids re-encoding both arrays into memory). Isolated into its own function to + * keep the 7-arg `beforeHook` stack shallow. + */ + function _validateBatch(bytes calldata _terms, bytes calldata _executionCallData) private pure { Execution[] calldata executions_ = _executionCallData.decodeBatch(); - Execution[] memory termsExecutions_ = getTermsInfo(_terms); + Execution[] calldata termsExecutions_ = _terms.decodeBatch(); - // Validate that the number of executions matches require(executions_.length == termsExecutions_.length, "ExactExecutionBatchEnforcer:invalid-batch-size"); - - // Encode both sets of executions and compare the hashes - require( - keccak256(abi.encode(executions_)) == keccak256(abi.encode(termsExecutions_)), - "ExactExecutionBatchEnforcer:invalid-execution" - ); + require(keccak256(_executionCallData) == keccak256(_terms), "ExactExecutionBatchEnforcer:invalid-execution"); } /** diff --git a/src/enforcers/ExactExecutionBatchLimitedCallsEnforcer.sol b/src/enforcers/ExactExecutionBatchLimitedCallsEnforcer.sol new file mode 100644 index 00000000..b63b4d6d --- /dev/null +++ b/src/enforcers/ExactExecutionBatchLimitedCallsEnforcer.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode, Execution } from "../utils/Types.sol"; + +/** + * @title ExactExecutionBatchLimitedCallsEnforcer + * @notice A gas-optimized caveat enforcer that BLENDS {ExactExecutionBatchEnforcer} and {LimitedCallsEnforcer} into a single + * `beforeHook`. In one external call it (1) pins the batch execution exactly (target, value, calldata of every + * execution) and (2) enforces a maximum number of redemptions of the delegation (replay / limited-calls). Using this + * one enforcer instead of the two separate caveats saves the manager a whole external call — and a cold account + * access — per redemption. + * + * @dev TERMS LAYOUT — `abi.encodePacked(uint256 limit, ExecutionLib.encodeBatch(Execution[] expectedExecutions))`: + * - `terms[0:32]` : the maximum number of redemptions (LimitedCalls semantics). + * - `terms[32:]` : the expected batch encoding. For a batch-mode redemption `executionCallData` IS + * `ExecutionLib.encodeBatch(Execution[])` (= `abi.encode(Execution[])`), so the exact-execution check + * is a single keccak comparison of `terms[32:]` against `executionCallData` — no decode/re-encode. + * + * @dev Operates only in BATCH call type + DEFAULT execution mode (like {ExactExecutionBatchEnforcer}). A single-execution + * gasless action can use this enforcer by encoding it as a one-element batch. + * + * @dev Unlike {LimitedCallsEnforcer} it does NOT emit an `IncreasedCount` event (saves a LOG3, ~1.9k gas). The on-chain + * `callCounts` mapping remains the authoritative, queryable replay state. + */ +contract ExactExecutionBatchLimitedCallsEnforcer is CaveatEnforcer { + ////////////////////////////// State ////////////////////////////// + + /// @dev Per-manager, per-delegation redemption counter (the authoritative, queryable replay state). + mapping(address delegationManager => mapping(bytes32 delegationHash => uint256 count)) public callCounts; + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Validates the batch execution matches exactly AND that the per-delegation call limit is not exceeded. + * @param _terms `abi.encodePacked(uint256 limit, encodeBatch(Execution[] expectedExecutions))`. + * @param _mode The execution mode (must be Batch callType, Default execType). + * @param _executionCallData The batch execution calldata (`abi.encode(Execution[])`). + * @param _delegationHash The hash of the delegation being redeemed (the replay-counter key). + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address + ) + public + override + onlyBatchCallTypeMode(_mode) + onlyDefaultExecutionMode(_mode) + { + require(_terms.length >= 32, "ExactExecutionBatchLimitedCallsEnforcer:invalid-terms-length"); + + // (1) Exact batch execution: the expected batch (`terms[32:]`) must byte-match the executionCallData. Both are + // `ExecutionLib.encodeBatch(Execution[])`, so byte equality is exactly batch equality (no decode/re-encode). + require( + keccak256(_terms[32:]) == keccak256(_executionCallData), "ExactExecutionBatchLimitedCallsEnforcer:invalid-execution" + ); + + // (2) Limited calls (replay): increment the per-(manager, delegation) counter and bound it by the limit (`terms[0:32]`). + uint256 callCount_ = ++callCounts[msg.sender][_delegationHash]; + require(callCount_ <= uint256(bytes32(_terms[0:32])), "ExactExecutionBatchLimitedCallsEnforcer:limit-exceeded"); + } + + /** + * @notice Decodes the terms into the call limit and the expected executions. + * @param _terms The encoded terms (see {beforeHook}). + * @return limit_ The maximum number of redemptions. + * @return executions_ The expected batch of executions. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (uint256 limit_, Execution[] memory executions_) { + require(_terms.length >= 32, "ExactExecutionBatchLimitedCallsEnforcer:invalid-terms-length"); + limit_ = uint256(bytes32(_terms[0:32])); + executions_ = ExecutionLib.decodeBatch(_terms[32:]); + } +} diff --git a/src/enforcers/ExactExecutionEnforcer.sol b/src/enforcers/ExactExecutionEnforcer.sol index eb8f8fc0..2815d43f 100644 --- a/src/enforcers/ExactExecutionEnforcer.sol +++ b/src/enforcers/ExactExecutionEnforcer.sol @@ -59,13 +59,8 @@ contract ExactExecutionEnforcer is CaveatEnforcer { * @dev Reverts if any part of the execution (target, value, or calldata) does not match the expected terms. */ function _validateExecution(bytes calldata _terms, bytes calldata _executionCallData) private pure { - // Decode execution data - (address execTarget_, uint256 execValue_, bytes calldata execCallData_) = _executionCallData.decodeSingle(); - - require( - address(bytes20(_terms[0:20])) == execTarget_ && uint256(bytes32(_terms[20:52])) == execValue_ - && keccak256(_terms[52:]) == keccak256(execCallData_), - "ExactExecutionEnforcer:invalid-execution" - ); + // `_terms` and `_executionCallData` are both `ExecutionLib.encodeSingle(target, value, callData)` (packed), so byte + // equality is exactly execution equality. A single keccak comparison avoids decoding both into their fields. + require(keccak256(_terms) == keccak256(_executionCallData), "ExactExecutionEnforcer:invalid-execution"); } } diff --git a/src/interfaces/IEIP7702BatchDeleGator.sol b/src/interfaces/IEIP7702BatchDeleGator.sol new file mode 100644 index 00000000..feada431 --- /dev/null +++ b/src/interfaces/IEIP7702BatchDeleGator.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Execution } from "@erc7579/interfaces/IERC7579Account.sol"; + +/** + * @title IEIP7702BatchDeleGator + * @notice Relay-only ERC-7821 batch execution surface for EIP7702BatchDeleGator. + * @dev Signed batches are submitted through `executeBatch`, not inherited `execute(ModeCode,bytes)`. + */ +interface IEIP7702BatchDeleGator { + /// @dev Single batch with optional `opData` — `abi.encode(Execution[], bytes)`. + function MODE_BATCH_WITH_OPDATA() external view returns (bytes32); + + /// @dev Nested signed batches — `abi.encode(bytes[])`. + function MODE_BATCH_OF_BATCHES() external view returns (bytes32); + + /** + * @notice Executes a signed ERC-7821 batch after authorization checks. + * @param mode Relay mode constant (`MODE_BATCH_WITH_OPDATA` or `MODE_BATCH_OF_BATCHES`). + * @param executionData Encoded batch payload for the selected mode. + */ + function executeBatch(bytes32 mode, bytes calldata executionData) external payable; + + /** + * @notice Returns whether `mode` is supported by the relay entrypoint. + * @dev Relay-only modes are intentionally excluded from inherited `supportsExecutionMode`. + */ + function supportsBatchExecutionMode(bytes32 mode) external view returns (bool); + + /** + * @notice EIP-712 digest for replay-protected relayed execution. + * @param executions Executions authorized by the signature. + * @param nonce Unordered nonce to consume if the batch executes. + * @param deadline Last timestamp at which the authorization is valid. + * @param relayer Optional authorized relayer; use `address(0)` to allow any relayer. + */ + function hashBatchAuthorizationWithNonce( + Execution[] calldata executions, + uint256 nonce, + uint256 deadline, + address relayer + ) + external + view + returns (bytes32); + + /// @notice Returns whether an unordered relay nonce has already been consumed or invalidated. + function isNonceUsed(uint256 nonce) external view returns (bool); + + /// @notice Returns the used-nonce bitmap for `word`. + function nonceBitmap(uint256 word) external view returns (uint256 bitmap); + + /// @notice Invalidates one relay nonce. + function invalidateNonce(uint256 nonce) external; + + /// @notice Invalidates any nonce bits in `word` where `mask` has a 1 bit. + function invalidateNonces(uint256 word, uint256 mask) external; +} diff --git a/src/libraries/BatchAuthorizationLib.sol b/src/libraries/BatchAuthorizationLib.sol new file mode 100644 index 00000000..45eb6f04 --- /dev/null +++ b/src/libraries/BatchAuthorizationLib.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Execution } from "@erc7579/interfaces/IERC7579Account.sol"; + +/** + * @title BatchAuthorizationLib + * @notice Shared helpers for EIP-712 batch authorization digests. + */ +library BatchAuthorizationLib { + bytes32 internal constant BATCH_AUTH_WITH_NONCE_TYPEHASH = + keccak256("BatchAuthorizationWithNonce(bytes32 callsDigest,uint256 nonce,uint256 deadline,address relayer)"); + + /// @notice Computes the ordered digest over `(target, value, keccak256(callData))` for each execution. + function executionsDigest(Execution[] memory executions) internal pure returns (bytes32 digest) { + uint256 len = executions.length; + bytes memory encoded = _newExecutionsDigestBuffer(len); + + for (uint256 i = 0; i < len;) { + Execution memory execution = executions[i]; + bytes32 dataHash = keccak256(execution.callData); + + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, mload(execution)) + mstore(add(ptr, 0x20), mload(add(execution, 0x20))) + mstore(add(ptr, 0x40), dataHash) + mstore(add(add(encoded, 0x60), shl(5, i)), keccak256(ptr, 0x60)) + } + + unchecked { + ++i; + } + } + + /// @solidity memory-safe-assembly + assembly { + digest := keccak256(add(encoded, 0x20), mload(encoded)) + } + } + + /// @notice Computes the ordered digest over calldata executions. + function executionsDigestCalldata(Execution[] calldata executions) internal pure returns (bytes32 digest) { + uint256 len = executions.length; + bytes memory encoded = _newExecutionsDigestBuffer(len); + + for (uint256 i = 0; i < len;) { + Execution calldata execution = executions[i]; + address target = execution.target; + uint256 value = execution.value; + bytes32 dataHash = keccak256(execution.callData); + + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, target) + mstore(add(ptr, 0x20), value) + mstore(add(ptr, 0x40), dataHash) + mstore(add(add(encoded, 0x60), shl(5, i)), keccak256(ptr, 0x60)) + } + + unchecked { + ++i; + } + } + + /// @solidity memory-safe-assembly + assembly { + digest := keccak256(add(encoded, 0x20), mload(encoded)) + } + } + + function batchAuthorizationWithNonceStructHash( + bytes32 callsDigest, + uint256 nonce, + uint256 deadline, + address relayer + ) + internal + pure + returns (bytes32 structHash) + { + bytes32 typeHash = BATCH_AUTH_WITH_NONCE_TYPEHASH; + + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, typeHash) + mstore(add(ptr, 0x20), callsDigest) + mstore(add(ptr, 0x40), nonce) + mstore(add(ptr, 0x60), deadline) + mstore(add(ptr, 0x80), relayer) + structHash := keccak256(ptr, 0xa0) + } + } + + function _newExecutionsDigestBuffer(uint256 len) private pure returns (bytes memory encoded) { + uint256 encodedLen; + unchecked { + encodedLen = 0x40 + (len << 5); + } + encoded = new bytes(encodedLen); + + /// @solidity memory-safe-assembly + assembly { + mstore(add(encoded, 0x20), 0x20) + mstore(add(encoded, 0x40), len) + } + } +} diff --git a/src/libraries/HookFlagsLib.sol b/src/libraries/HookFlagsLib.sol deleted file mode 100644 index a53a9e54..00000000 --- a/src/libraries/HookFlagsLib.sol +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: MIT AND Apache-2.0 -pragma solidity 0.8.23; - -/** - * @title HookFlagsLib - * @notice Uniswap-v4-style hook-permission flags encoded in the LOW NIBBLE (lowest 4 bits) of a caveat-enforcer address. - * @dev Inspired by Uniswap v4's `Hooks` library, which packs each callback's permission into a bit of the hook contract's - * address and gates every callback with a pure `uint160(addr) & FLAG != 0` test — so the core never makes an external - * call into a phase a hook didn't opt into. We apply the same idea to `CaveatEnforcer`'s four hook phases. - * - * An enforcer's hook permissions are therefore carried by its ADDRESS, which is part of the EIP-712-signed - * `Caveat.enforcer` (see `EncoderLib._getCaveatPacketHash`). The flags are thus committed by the delegator's signature - * and cannot be forged without breaking the delegation hash / chain-authority check — exactly v4's trust model. - * - * To deploy an enforcer at a flag-bearing address, mine a CREATE2 salt until the resulting address's low nibble equals - * the desired flag bits (see the comparison test's `_deployFlagged` helper). Enforcers that only implement `beforeHook` - * (e.g. ExactExecution*, LimitedCalls) want a low nibble of `BEFORE_HOOK_FLAG` (0x4). - */ -library HookFlagsLib { - /// @dev The enforcer implements `beforeAllHook`. - uint160 internal constant BEFORE_ALL_HOOK_FLAG = 1 << 3; // 0x08 - - /// @dev The enforcer implements `beforeHook`. - uint160 internal constant BEFORE_HOOK_FLAG = 1 << 2; // 0x04 - - /// @dev The enforcer implements `afterHook`. - uint160 internal constant AFTER_HOOK_FLAG = 1 << 1; // 0x02 - - /// @dev The enforcer implements `afterAllHook`. - uint160 internal constant AFTER_ALL_HOOK_FLAG = 1 << 0; // 0x01 - - /// @dev Mask covering all hook-permission bits (the low nibble). - uint160 internal constant HOOK_FLAG_MASK = (1 << 4) - 1; // 0x0f - - /** - * @notice Pure bit-test for a hook permission on an enforcer address — zero storage reads, zero external calls. - * @param _enforcer The caveat-enforcer address whose low-nibble encodes its hook permissions. - * @param _flag The hook-permission flag to test for. - * @return True if the enforcer's address has the given flag bit set. - */ - function hasFlag(address _enforcer, uint160 _flag) internal pure returns (bool) { - return uint160(_enforcer) & _flag != 0; - } -} diff --git a/test/Erc7821BaselineBenchmark.t.sol b/test/Erc7821BaselineBenchmark.t.sol new file mode 100644 index 00000000..f6c7aed6 --- /dev/null +++ b/test/Erc7821BaselineBenchmark.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { console } from "forge-std/console.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { BaseTest } from "./utils/BaseTest.t.sol"; +import { Implementation, SignatureType } from "./utils/Types.t.sol"; +import { Execution } from "../src/utils/Types.sol"; +import { SigningUtilsLib } from "./utils/SigningUtilsLib.t.sol"; +import { BasicERC20 } from "./utils/BasicERC20.t.sol"; + +import { EIP7702BatchDeleGator } from "../src/EIP7702/EIP7702BatchDeleGator.sol"; + +/** + * @title ERC-7821 relay-batch gas baseline (the floor to beat) + * + * @notice Benchmarks the ADR Option-1 ERC-7821 signed relay-batch flow on an EIP-7702 account: the relayer submits + * `executeBatch(MODE_BATCH_WITH_OPDATA, abi.encode(Execution[], opData))` directly to the user's EOA, which validates + * an EIP-712 `BatchAuthorizationWithNonce` signature + an unordered nonce bitmap, then executes the batch. There is no + * DelegationManager, no caveat enforcers, and no EntryPoint — this is the lower-gas execution shape the optimized + * `SimpleDelegationManager` is being measured against. + * + * @dev Same measurement methodology as the other benchmarks: the 7702 designator is installed with `vm.etch` in setUp (so the + * upgrade is excluded), and only the `executeBatch` call is bracketed with `gasleft()`. Reports execution gas, calldata + * gas (EIP-2028), and the estimated standalone tx gas (21k intrinsic + calldata + execution). Run with `-vv`. + * + * @dev Uses the EIP7702BatchDeleGator from branch `cursor/eip7702-batch-delegator-o1-1-o1-2`. + */ +contract Erc7821BaselineBenchmark is BaseTest { + constructor() { + IMPLEMENTATION = Implementation.EIP7702Stateless; + SIGNATURE_TYPE = SignatureType.EOA; + } + + uint256 internal constant INTRINSIC_GAS = 21_000; + uint256 internal constant SWAP_AMOUNT = 100e18; + uint256 internal constant SEND_AMOUNT = 50e18; + uint256 internal constant FEE_AMOUNT = 1e18; + + EIP7702BatchDeleGator internal batchImpl; + BasicERC20 internal token; + + address internal relayer; + address internal recipient; + address internal feeAccount; + + function setUp() public override { + super.setUp(); + + // Deploy the ERC-7821 batch DeleGator implementation and install it on Alice's EOA (the upgrade is excluded). + batchImpl = new EIP7702BatchDeleGator(delegationManager, entryPoint); + vm.label(address(batchImpl), "EIP7702BatchDeleGator Impl"); + vm.etch(users.alice.addr, bytes.concat(hex"ef0100", abi.encodePacked(address(batchImpl)))); + + token = new BasicERC20(address(this), "Mock USDC", "USDC", 0); + token.mint(users.alice.addr, 1_000_000e18); + vm.label(address(token), "MockUSDC"); + + relayer = makeAddr("Relayer"); + recipient = makeAddr("Recipient"); + feeAccount = makeAddr("MetaMaskFeeAccount"); + } + + /// @notice ERC-7821 floor for the gasless swap: a 1-execution signed batch. + function test_bench_erc7821_swap() public { + Execution[] memory executions_ = new Execution[](1); + executions_[0] = Execution({ + target: address(token), value: 0, callData: abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT) + }); + + _benchExecuteBatch("ERC-7821 floor | gasless swap | 1-exec signed batch", executions_); + assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "swap proceeds reached recipient"); + } + + /// @notice ERC-7821 floor for the gasless transaction: a 2-execution signed batch (user action + fee). + function test_bench_erc7821_gaslessTransaction() public { + Execution[] memory executions_ = new Execution[](2); + executions_[0] = Execution({ + target: address(token), value: 0, callData: abi.encodeWithSelector(IERC20.transfer.selector, recipient, SEND_AMOUNT) + }); + executions_[1] = Execution({ + target: address(token), value: 0, callData: abi.encodeWithSelector(IERC20.transfer.selector, feeAccount, FEE_AMOUNT) + }); + + _benchExecuteBatch("ERC-7821 floor | gasless transaction | 2-exec signed batch", executions_); + assertEq(token.balanceOf(recipient), SEND_AMOUNT, "user action reached recipient"); + assertEq(token.balanceOf(feeAccount), FEE_AMOUNT, "fee leg reached fee account"); + } + + ////////////////////////////// Internal ////////////////////////////// + + /// @dev Builds a relayer-signed ERC-7821 batch authorization and measures ONLY the executeBatch call. + function _benchExecuteBatch(string memory _label, Execution[] memory _executions) internal { + EIP7702BatchDeleGator account_ = EIP7702BatchDeleGator(payable(users.alice.addr)); + + uint256 nonce_ = 1; + uint256 deadline_ = block.timestamp + 1 hours; + + bytes32 digest_ = account_.hashBatchAuthorizationWithNonce(_executions, nonce_, deadline_, relayer); + bytes memory signature_ = SigningUtilsLib.signHash_EOA(users.alice.privateKey, digest_); + bytes memory opData_ = abi.encode(nonce_, deadline_, relayer, signature_); + bytes memory executionData_ = abi.encode(_executions, opData_); + + bytes memory cd_ = + abi.encodeWithSelector(EIP7702BatchDeleGator.executeBatch.selector, account_.MODE_BATCH_WITH_OPDATA(), executionData_); + + vm.prank(relayer); + uint256 gasBefore_ = gasleft(); + (bool ok_, bytes memory ret_) = address(account_).call(cd_); + uint256 executionGas_ = gasBefore_ - gasleft(); + if (!ok_) { + assembly { + revert(add(ret_, 0x20), mload(ret_)) + } + } + + uint256 calldataGas_ = _calldataGas(cd_); + console.log("====================================================================="); + console.log(_label); + console.log(string.concat(" executeBatch execution gas ........ ", vm.toString(executionGas_))); + console.log(string.concat(" calldata size (bytes) ............. ", vm.toString(cd_.length))); + console.log(string.concat(" calldata gas (EIP-2028) ........... ", vm.toString(calldataGas_))); + console.log( + string.concat(" est. standalone tx gas (excl. 7702) ", vm.toString(INTRINSIC_GAS + calldataGas_ + executionGas_)) + ); + console.log("====================================================================="); + } + + function _calldataGas(bytes memory _data) internal pure returns (uint256 gas_) { + uint256 len_ = _data.length; + for (uint256 i; i < len_; ++i) { + gas_ += _data[i] == 0x00 ? 4 : 16; + } + } +} diff --git a/test/SimpleDelegationManagerComparison.t.sol b/test/SimpleDelegationManagerComparison.t.sol index ff24bb8b..9350e150 100644 --- a/test/SimpleDelegationManagerComparison.t.sol +++ b/test/SimpleDelegationManagerComparison.t.sol @@ -11,7 +11,6 @@ import { BaseTest } from "./utils/BaseTest.t.sol"; import { Implementation, SignatureType, TestUser } from "./utils/Types.t.sol"; import { Execution, Caveat, Delegation, ModeCode } from "../src/utils/Types.sol"; import { EncoderLib } from "../src/libraries/EncoderLib.sol"; -import { HookFlagsLib } from "../src/libraries/HookFlagsLib.sol"; import { SigningUtilsLib } from "./utils/SigningUtilsLib.t.sol"; import { StorageUtilsLib } from "./utils/StorageUtilsLib.t.sol"; import { BasicERC20 } from "./utils/BasicERC20.t.sol"; @@ -22,9 +21,7 @@ import { DelegationManager } from "../src/DelegationManager.sol"; import { SimpleDelegationManager } from "../src/SimpleDelegationManager.sol"; import { EIP7702MultiManagerDeleGator } from "../src/EIP7702/EIP7702MultiManagerDeleGator.sol"; import { EIP7702MultiManagerDeleGatorCore } from "../src/EIP7702/EIP7702MultiManagerDeleGatorCore.sol"; -import { ExactExecutionEnforcer } from "../src/enforcers/ExactExecutionEnforcer.sol"; -import { ExactExecutionBatchEnforcer } from "../src/enforcers/ExactExecutionBatchEnforcer.sol"; -import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; +import { ExactExecutionBatchLimitedCallsEnforcer } from "../src/enforcers/ExactExecutionBatchLimitedCallsEnforcer.sol"; /** * @title SimpleDelegationManager vs DelegationManager — side-by-side gas comparison @@ -32,19 +29,22 @@ import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol" * @notice Benchmarks `redeemDelegations` gas for the gasless flows on an EIP-7702 account, comparing the canonical * `DelegationManager` against the gas-optimized `SimpleDelegationManager` (ADR #0002 Option 3). * + * @dev The gasless flows use a SINGLE combined caveat — {ExactExecutionBatchLimitedCallsEnforcer}, which blends + * `ExactExecutionBatchEnforcer` (pin the exact batch) + `LimitedCallsEnforcer` (one-shot replay) into one `beforeHook`. + * Because it is a BATCH enforcer, both flows run in batch mode: the gasless swap is a one-element batch, the gasless + * transaction a two-element batch (user action + fee). + * * @dev SETUP that makes the comparison FAIR (only the manager logic differs): - * - The delegator is a NEW {EIP7702MultiManagerDeleGator} 7702 account that approves BOTH managers (an EIP-7201 - * approved-manager set replaces the single immutable manager), so the SAME account can be driven by either. - * - Both managers redeem the SAME caveats against the SAME enforcer instances. Those enforcers are deployed at - * CREATE2-mined, flag-bearing addresses (low nibble = {HookFlagsLib.BEFORE_HOOK_FLAG}), so `SimpleDelegationManager` - * can skip the no-op hook phases via a pure address-bit test (Uniswap-v4-style), while `DelegationManager` ignores - * the bits and calls all four phases — exactly the overhead being measured. - * - Each manager is measured from an IDENTICAL cold state via `vm.snapshot()` / `vm.revertTo()`, so warm-storage - * ordering does not bias the result. - * - The 7702 "upgrade" is installed with `vm.etch` in setUp, so its gas is excluded (see - * OptimizedDelegationManagerBenchmark.t.sol for the rationale). Gas is measured with the portable `gasleft()` bracket. + * - The delegator is a {EIP7702MultiManagerDeleGator} 7702 account that approves BOTH managers (an EIP-7201 + * approved-manager set), so the SAME account can be driven by either. + * - Both managers redeem the SAME caveat against the SAME enforcer instance. `SimpleDelegationManager` runs ONLY the + * `beforeHook` phase, while the canonical `DelegationManager` runs all four phases (the other three are inherited + * no-ops on this enforcer) — exactly the per-caveat overhead being measured. + * - Each manager is measured from an IDENTICAL cold state via `vm.snapshot()` / `vm.revertTo()`. + * - The 7702 "upgrade" is installed with `vm.etch` in setUp, so its gas is excluded. Gas is measured with the portable + * `gasleft()` bracket. * - * @dev Run with `-vv` to print the comparison. Use `--isolate` for cold per-call absolute numbers. + * @dev Run with `-vv` to print the comparison. */ contract SimpleDelegationManagerComparison is BaseTest { using MessageHashUtils for bytes32; @@ -62,6 +62,7 @@ contract SimpleDelegationManagerComparison is BaseTest { uint256 internal constant SWAP_AMOUNT = 100e18; uint256 internal constant SEND_AMOUNT = 50e18; uint256 internal constant FEE_AMOUNT = 1e18; + uint256 internal constant CALL_LIMIT = 1; // one-shot replay protection /// @dev keccak256(abi.encode(uint256(keccak256("DeleGator.EIP7702MultiManager")) - 1)) & ~bytes32(uint256(0xff)) bytes32 internal constant EXPECTED_MULTI_MANAGER_SLOT = 0x49e56a63dc56241c65d46138ca3c27c5bf7b4df245f96cb568e8e7ba7c940400; @@ -72,9 +73,7 @@ contract SimpleDelegationManagerComparison is BaseTest { SimpleDelegationManager internal simpleManager; // the gas-optimized manager EIP7702MultiManagerDeleGator internal multiManagerImpl; // the new multi-manager 7702 account implementation - ExactExecutionEnforcer internal exactExecutionEnforcer; - ExactExecutionBatchEnforcer internal exactExecutionBatchEnforcer; - LimitedCallsEnforcer internal limitedCallsEnforcer; + ExactExecutionBatchLimitedCallsEnforcer internal comboEnforcer; // ExactExecutionBatch + LimitedCalls, blended BasicERC20 internal token; Counter internal counter; @@ -105,13 +104,10 @@ contract SimpleDelegationManagerComparison is BaseTest { aliceAccount_.approveDelegationManager(IDelegationManager(address(simpleManager))); vm.stopPrank(); - // Deploy the three gasless enforcers at flag-bearing addresses (BEFORE_HOOK_FLAG only) via CREATE2 salt mining. - exactExecutionEnforcer = ExactExecutionEnforcer(_deployFlagged(type(ExactExecutionEnforcer).creationCode)); - exactExecutionBatchEnforcer = ExactExecutionBatchEnforcer(_deployFlagged(type(ExactExecutionBatchEnforcer).creationCode)); - limitedCallsEnforcer = LimitedCallsEnforcer(_deployFlagged(type(LimitedCallsEnforcer).creationCode)); - vm.label(address(exactExecutionEnforcer), "ExactExecutionEnforcer(flagged)"); - vm.label(address(exactExecutionBatchEnforcer), "ExactExecutionBatchEnforcer(flagged)"); - vm.label(address(limitedCallsEnforcer), "LimitedCallsEnforcer(flagged)"); + // The single combined gasless enforcer (both managers use the same instance; SimpleDelegationManager only invokes + // beforeHook). + comboEnforcer = new ExactExecutionBatchLimitedCallsEnforcer(); + vm.label(address(comboEnforcer), "ExactExecutionBatchLimitedCallsEnforcer"); token = new BasicERC20(address(this), "Mock USDC", "USDC", 0); token.mint(users.alice.addr, 1_000_000e18); @@ -136,30 +132,18 @@ contract SimpleDelegationManagerComparison is BaseTest { ); } - /// @notice Each gasless enforcer is deployed at an address advertising BEFORE_HOOK_FLAG (and nothing else). - function test_enforcers_haveBeforeHookFlagOnly() public { - _assertBeforeHookOnly(address(exactExecutionEnforcer)); - _assertBeforeHookOnly(address(exactExecutionBatchEnforcer)); - _assertBeforeHookOnly(address(limitedCallsEnforcer)); - } - /// @notice The multi-manager account rejects an unapproved manager trying to drive it. function test_multiManager_rejectsUnapprovedManager() public { SimpleDelegationManager rogue_ = new SimpleDelegationManager(); bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); - Caveat[] memory caveats_ = _gaslessSwapCaveats(swapCallData_); - Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, caveats_); + Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, _gaslessSwapCaveats(swapCallData_)); Delegation[] memory delegations_ = new Delegation[](1); delegations_[0] = _signDelegationFor(IDelegationManager(address(rogue_)), users.alice, unsigned_); - bytes[] memory pc_ = new bytes[](1); - pc_[0] = abi.encode(delegations_); - ModeCode[] memory modes_ = new ModeCode[](1); - modes_[0] = ModeLib.encodeSimpleSingle(); - bytes[] memory ecd_ = new bytes[](1); - ecd_[0] = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = + _redeemArgs(delegations_, ModeLib.encodeSimpleBatch(), _swapExecData(swapCallData_)); vm.prank(relayer); vm.expectRevert(EIP7702MultiManagerDeleGatorCore.NotDelegationManager.selector); @@ -173,9 +157,7 @@ contract SimpleDelegationManagerComparison is BaseTest { bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); Delegation[] memory delegations_ = _signedGaslessSwap(relayer, swapCallData_); - _redeemSimple( - relayer, delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_) - ); + _redeemSimple(relayer, delegations_, ModeLib.encodeSimpleBatch(), _swapExecData(swapCallData_)); assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "swap proceeds reached recipient"); } @@ -185,9 +167,7 @@ contract SimpleDelegationManagerComparison is BaseTest { bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); Delegation[] memory delegations_ = _signedGaslessSwap(ANY_DELEGATE, swapCallData_); - _redeemSimple( - randomRedeemer_, delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_) - ); + _redeemSimple(randomRedeemer_, delegations_, ModeLib.encodeSimpleBatch(), _swapExecData(swapCallData_)); assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "ANY_DELEGATE redemption succeeded"); } @@ -196,7 +176,7 @@ contract SimpleDelegationManagerComparison is BaseTest { bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); Delegation[] memory delegations_ = _signedGaslessSwap(relayer, swapCallData_); (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = - _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + _redeemArgs(delegations_, ModeLib.encodeSimpleBatch(), _swapExecData(swapCallData_)); vm.prank(makeAddr("NotTheDelegate")); vm.expectRevert(IDelegationManager.InvalidDelegate.selector); @@ -230,7 +210,7 @@ contract SimpleDelegationManagerComparison is BaseTest { delegations_[0] = leaf_; delegations_[1] = root_; (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = - _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + _redeemArgs(delegations_, ModeLib.encodeSimpleBatch(), _swapExecData(swapCallData_)); vm.prank(relayer); vm.expectRevert(IDelegationManager.InvalidAuthority.selector); @@ -246,7 +226,7 @@ contract SimpleDelegationManagerComparison is BaseTest { delegations_[0] = _signDelegationFor(IDelegationManager(address(simpleManager)), users.bob, unsigned_); (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = - _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + _redeemArgs(delegations_, ModeLib.encodeSimpleBatch(), _swapExecData(swapCallData_)); vm.prank(relayer); vm.expectRevert(IDelegationManager.InvalidERC1271Signature.selector); @@ -263,25 +243,25 @@ contract SimpleDelegationManagerComparison is BaseTest { simpleManager.disableDelegation(delegations_[0]); (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = - _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), ExecutionLib.encodeSingle(address(token), 0, swapCallData_)); + _redeemArgs(delegations_, ModeLib.encodeSimpleBatch(), _swapExecData(swapCallData_)); vm.prank(relayer); vm.expectRevert(IDelegationManager.CannotUseADisabledDelegation.selector); simpleManager.redeemDelegations(pc_, modes_, ecd_); } - /// @notice LimitedCallsEnforcer (limit = 1) blocks a second redemption of the same delegation. + /// @notice The combined enforcer's limit (= 1) blocks a second redemption of the same delegation. function test_simple_limitedCalls_blocksReplay() public { bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); Delegation[] memory delegations_ = _signedGaslessSwap(relayer, swapCallData_); - bytes memory execData_ = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); + bytes memory execData_ = _swapExecData(swapCallData_); - _redeemSimple(relayer, delegations_, ModeLib.encodeSimpleSingle(), execData_); + _redeemSimple(relayer, delegations_, ModeLib.encodeSimpleBatch(), execData_); (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = - _redeemArgs(delegations_, ModeLib.encodeSimpleSingle(), execData_); + _redeemArgs(delegations_, ModeLib.encodeSimpleBatch(), execData_); vm.prank(relayer); - vm.expectRevert(bytes("LimitedCallsEnforcer:limit-exceeded")); + vm.expectRevert(bytes("ExactExecutionBatchLimitedCallsEnforcer:limit-exceeded")); simpleManager.redeemDelegations(pc_, modes_, ecd_); } @@ -298,23 +278,22 @@ contract SimpleDelegationManagerComparison is BaseTest { assertEq(counter.count(), 1, "counter incremented exactly once (post-revert state)"); } - /// @notice Gasless swap: single execution, ExactExecution + LimitedCalls. + /// @notice Gasless swap: one-element batch, gated by the combined ExactExecutionBatch + LimitedCalls enforcer. function test_compare_gaslessSwap_singleExecution() public { bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); - bytes memory execData_ = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, _gaslessSwapCaveats(swapCallData_)); _runComparison( - "gasless swap | single execution | ExactExecution + LimitedCalls", + "gasless swap | 1-exec batch | ExactExecutionBatch + LimitedCalls (combined)", unsigned_, users.alice, - ModeLib.encodeSimpleSingle(), - execData_ + ModeLib.encodeSimpleBatch(), + _swapExecData(swapCallData_) ); assertEq(token.balanceOf(recipient), SWAP_AMOUNT, "swap proceeds reached recipient"); } - /// @notice Gasless transaction: 2-execution batch (user transfer + fee transfer), ExactExecutionBatch + LimitedCalls. + /// @notice Gasless transaction: two-element batch (user transfer + fee transfer), gated by the combined enforcer. function test_compare_gaslessTransaction_batchTwoExecutions() public { Execution[] memory executions_ = new Execution[](2); executions_[0] = Execution({ @@ -325,14 +304,9 @@ contract SimpleDelegationManagerComparison is BaseTest { }); bytes memory execData_ = ExecutionLib.encodeBatch(executions_); - Caveat[] memory caveats_ = new Caveat[](2); - caveats_[0] = - Caveat({ enforcer: address(exactExecutionBatchEnforcer), terms: ExecutionLib.encodeBatch(executions_), args: hex"" }); - caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); - - Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, caveats_); + Delegation memory unsigned_ = _rootDelegation(relayer, users.alice.addr, _comboCaveats(executions_)); _runComparison( - "gasless transaction | 2-exec batch | ExactExecutionBatch + LimitedCalls", + "gasless transaction | 2-exec batch | ExactExecutionBatch + LimitedCalls (combined)", unsigned_, users.alice, ModeLib.encodeSimpleBatch(), @@ -345,8 +319,8 @@ contract SimpleDelegationManagerComparison is BaseTest { /// @notice Gasless swap over a 2-link chain (root Alice->Bob, leaf Bob->relayer): measures leaf-to-root validation cost. function test_compare_gaslessSwap_chainedDelegation() public { bytes memory swapCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); - bytes memory execData_ = ExecutionLib.encodeSingle(address(token), 0, swapCallData_); - ModeCode mode_ = ModeLib.encodeSimpleSingle(); + bytes memory execData_ = _swapExecData(swapCallData_); + ModeCode mode_ = ModeLib.encodeSimpleBatch(); // Build the unsigned chain once; sign per-manager domain inside the comparison. Delegation memory rootUnsigned_ = _rootDelegation(address(users.bob.addr), users.alice.addr, new Caveat[](0)); @@ -402,7 +376,7 @@ contract SimpleDelegationManagerComparison is BaseTest { vm.revertTo(snap_); (uint256 simpleGas_,) = _measureRedeem(IDelegationManager(address(simpleManager)), relayer, forSimple_, _mode, _execData); - // Functional equivalence: both managers must produce byte-identical observable effects on the tracked state. + // Functional equivalence: both managers must produce identical observable effects on the tracked state. assertEq(token.balanceOf(recipient), curRecipient_, "recipient effect must match across managers"); assertEq(token.balanceOf(feeAccount), curFee_, "fee effect must match across managers"); assertEq(counter.count(), curCounter_, "counter effect must match across managers"); @@ -444,12 +418,7 @@ contract SimpleDelegationManagerComparison is BaseTest { internal returns (uint256 executionGas_, bytes memory cd_) { - bytes[] memory pc_ = new bytes[](1); - pc_[0] = abi.encode(_delegations); - ModeCode[] memory modes_ = new ModeCode[](1); - modes_[0] = _mode; - bytes[] memory ecd_ = new bytes[](1); - ecd_[0] = _execData; + (bytes[] memory pc_, ModeCode[] memory modes_, bytes[] memory ecd_) = _redeemArgs(_delegations, _mode, _execData); cd_ = abi.encodeWithSelector(IDelegationManager.redeemDelegations.selector, pc_, modes_, ecd_); @@ -490,7 +459,7 @@ contract SimpleDelegationManagerComparison is BaseTest { ////////////////////////////// Internal: builders ////////////////////////////// - /// @dev Builds + signs (for SimpleDelegationManager's domain) a single gasless-swap root delegation. + /// @dev Builds + signs (for SimpleDelegationManager's domain) a single gasless-swap root delegation (one-element batch). function _signedGaslessSwap( address _delegate, bytes memory _swapCallData @@ -529,14 +498,31 @@ contract SimpleDelegationManagerComparison is BaseTest { ecd_[0] = _execData; } + /// @dev A one-element batch [swap] as a `BasicERC20.transfer` (stand-in for the swap-router call). + function _swapExecutions(bytes memory _swapCallData) internal view returns (Execution[] memory executions_) { + executions_ = new Execution[](1); + executions_[0] = Execution({ target: address(token), value: 0, callData: _swapCallData }); + } + + /// @dev The batch-encoded executionCallData for a one-element swap batch. + function _swapExecData(bytes memory _swapCallData) internal view returns (bytes memory) { + return ExecutionLib.encodeBatch(_swapExecutions(_swapCallData)); + } + + /// @dev The combined-enforcer caveat for a one-element swap batch. function _gaslessSwapCaveats(bytes memory _swapCallData) internal view returns (Caveat[] memory caveats_) { - caveats_ = new Caveat[](2); + return _comboCaveats(_swapExecutions(_swapCallData)); + } + + /// @dev A single combined-enforcer caveat pinning `_executions` exactly + bounding redemptions to CALL_LIMIT. + /// @dev terms = abi.encodePacked(uint256 limit, encodeBatch(Execution[])). + function _comboCaveats(Execution[] memory _executions) internal view returns (Caveat[] memory caveats_) { + caveats_ = new Caveat[](1); caveats_[0] = Caveat({ - enforcer: address(exactExecutionEnforcer), - terms: ExecutionLib.encodeSingle(address(token), 0, _swapCallData), + enforcer: address(comboEnforcer), + terms: abi.encodePacked(uint256(CALL_LIMIT), ExecutionLib.encodeBatch(_executions)), args: hex"" }); - caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); } function _rootDelegation( @@ -583,24 +569,6 @@ contract SimpleDelegationManagerComparison is BaseTest { vm.etch(_eoa, bytes.concat(hex"ef0100", abi.encodePacked(address(multiManagerImpl)))); } - /// @dev Mines a CREATE2 salt until the deployed address's low nibble equals BEFORE_HOOK_FLAG, then deploys via SimpleFactory. - function _deployFlagged(bytes memory _creationCode) internal returns (address addr_) { - bytes32 codeHash_ = keccak256(_creationCode); - for (uint256 salt_;; ++salt_) { - address predicted_ = simpleFactory.computeAddress(codeHash_, bytes32(salt_)); - if (uint160(predicted_) & HookFlagsLib.HOOK_FLAG_MASK == HookFlagsLib.BEFORE_HOOK_FLAG) { - return simpleFactory.deploy(_creationCode, bytes32(salt_)); - } - } - } - - function _assertBeforeHookOnly(address _enforcer) internal { - assertTrue(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.BEFORE_HOOK_FLAG), "missing BEFORE_HOOK_FLAG"); - assertFalse(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.AFTER_HOOK_FLAG), "unexpected AFTER_HOOK_FLAG"); - assertFalse(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.BEFORE_ALL_HOOK_FLAG), "unexpected BEFORE_ALL_HOOK_FLAG"); - assertFalse(HookFlagsLib.hasFlag(_enforcer, HookFlagsLib.AFTER_ALL_HOOK_FLAG), "unexpected AFTER_ALL_HOOK_FLAG"); - } - /// @dev EIP-2028 calldata cost: 4 gas per zero byte, 16 per non-zero byte. function _calldataGas(bytes memory _data) internal pure returns (uint256 gas_) { uint256 len_ = _data.length;