diff --git a/script/DeployEIP7702BatchDeleGator.s.sol b/script/DeployEIP7702BatchDeleGator.s.sol new file mode 100644 index 00000000..077bc765 --- /dev/null +++ b/script/DeployEIP7702BatchDeleGator.s.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Script.sol"; +import { console2 } from "forge-std/console2.sol"; +import { IEntryPoint } from "@account-abstraction/interfaces/IEntryPoint.sol"; + +import { EIP7702BatchDeleGator } from "../src/EIP7702/EIP7702BatchDeleGator.sol"; +import { EIP7702BatchDeleGatorBeacon } from "../src/EIP7702/EIP7702BatchDeleGatorBeacon.sol"; +import { EIP7702BatchDeleGatorProxy } from "../src/EIP7702/EIP7702BatchDeleGatorProxy.sol"; +import { DeleGatorBatchRelayCoordinator } from "../src/DeleGatorBatchRelayCoordinator.sol"; +import { IDelegationManager } from "../src/interfaces/IDelegationManager.sol"; + +/** + * @title DeployEIP7702BatchDeleGator + * @notice Deploys EIP7702BatchDeleGator, optional beacon/proxy, and the batch relay coordinator. + * @dev Does not broadcast by default. Run with `--broadcast` only when ready to deploy. + * @dev Required env: + * - SALT + * - ENTRYPOINT_ADDRESS + * - DELEGATION_MANAGER_ADDRESS + * @dev Optional env: + * - BEACON_OWNER (defaults to deployer) + * - DEPLOY_PROXY=true|false (defaults to true) + */ +contract DeployEIP7702BatchDeleGator is Script { + bytes32 internal salt; + IEntryPoint internal entryPoint; + IDelegationManager internal delegationManager; + address internal deployer; + address internal beaconOwner; + bool internal deployProxy; + + function setUp() public { + salt = bytes32(abi.encodePacked(vm.envString("SALT"))); + entryPoint = IEntryPoint(vm.envAddress("ENTRYPOINT_ADDRESS")); + delegationManager = IDelegationManager(vm.envAddress("DELEGATION_MANAGER_ADDRESS")); + deployer = msg.sender; + beaconOwner = vm.envOr("BEACON_OWNER", deployer); + deployProxy = vm.envOr("DEPLOY_PROXY", true); + + console2.log("~~~ DeployEIP7702BatchDeleGator ~~~"); + console2.log("Deployer: %s", deployer); + console2.log("Entry Point: %s", address(entryPoint)); + console2.log("Delegation Manager: %s", address(delegationManager)); + console2.log("Beacon Owner: %s", beaconOwner); + console2.log("Deploy Proxy: %s", deployProxy); + console2.log("Salt:"); + console2.logBytes32(salt); + } + + function run() public { + vm.startBroadcast(); + + address implementation = address( + new EIP7702BatchDeleGator{ salt: salt }(delegationManager, entryPoint) + ); + console2.log("EIP7702BatchDeleGatorImpl: %s", implementation); + + address authorizationTarget = implementation; + address beacon; + address proxy; + + if (deployProxy) { + beacon = address(new EIP7702BatchDeleGatorBeacon{ salt: salt }(implementation, beaconOwner)); + console2.log("EIP7702BatchDeleGatorBeacon: %s", beacon); + + proxy = address(new EIP7702BatchDeleGatorProxy{ salt: salt }(beacon)); + console2.log("EIP7702BatchDeleGatorProxy: %s", proxy); + + authorizationTarget = proxy; + } + + address coordinator = address(new DeleGatorBatchRelayCoordinator{ salt: salt }()); + console2.log("DeleGatorBatchRelayCoordinator: %s", coordinator); + + console2.log("~~~ Release Metadata ~~~"); + console2.log("authorizationTarget: %s", authorizationTarget); + console2.log("implementation: %s", implementation); + console2.log("beacon: %s", beacon); + console2.log("proxy: %s", proxy); + console2.log("coordinator: %s", coordinator); + console2.log("eip712Name: EIP7702BatchDeleGator"); + console2.log("eip712Version: 1"); + console2.log("contractVersion: %s", EIP7702BatchDeleGator(payable(implementation)).VERSION()); + + vm.stopBroadcast(); + } +} diff --git a/src/DeleGatorBatchRelayCoordinator.sol b/src/DeleGatorBatchRelayCoordinator.sol new file mode 100644 index 00000000..709f73b0 --- /dev/null +++ b/src/DeleGatorBatchRelayCoordinator.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IDeleGatorBatchRelayCoordinator } from "./interfaces/IDeleGatorBatchRelayCoordinator.sol"; +import { IEIP7702BatchDeleGator } from "./interfaces/IEIP7702BatchDeleGator.sol"; + +/** + * @title DeleGatorBatchRelayCoordinator + * @notice Permissionless multi-account coordinator for signed batch DeleGator relay execution. + * @dev Non-atomic by default: a failed account row is recorded and later rows still execute. + * @dev Does not forward ETH and does not authorize child account execution by itself. + */ +contract DeleGatorBatchRelayCoordinator is IDeleGatorBatchRelayCoordinator { + uint256 internal constant MAX_REVERT_DATA = 256; + + /// @dev Emitted for each coordinator row after execution attempt. + event BatchRowExecuted(uint256 indexed index, address indexed account, bool success, bytes revertData); + + /// @inheritdoc IDeleGatorBatchRelayCoordinator + function executeBatches(AccountBatch[] calldata batches) external { + uint256 len = batches.length; + for (uint256 i = 0; i < len;) { + AccountBatch calldata batch = batches[i]; + + (bool success, bytes memory revertData) = address(batch.account).call( + abi.encodeWithSelector(IEIP7702BatchDeleGator.executeBatch.selector, batch.mode, batch.executionData) + ); + + if (!success && revertData.length > MAX_REVERT_DATA) { + revertData = abi.encodePacked(keccak256(revertData)); + } + + emit BatchRowExecuted(i, batch.account, success, success ? bytes("") : revertData); + + unchecked { + ++i; + } + } + } +} 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/EIP7702/EIP7702BatchDeleGatorBeacon.sol b/src/EIP7702/EIP7702BatchDeleGatorBeacon.sol new file mode 100644 index 00000000..9a29ec65 --- /dev/null +++ b/src/EIP7702/EIP7702BatchDeleGatorBeacon.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; + +/// @notice Upgrade beacon for EIP7702BatchDeleGator implementations. +contract EIP7702BatchDeleGatorBeacon is UpgradeableBeacon { + constructor(address implementation_, address initialOwner) UpgradeableBeacon(implementation_, initialOwner) { } +} diff --git a/src/EIP7702/EIP7702BatchDeleGatorProxy.sol b/src/EIP7702/EIP7702BatchDeleGatorProxy.sol new file mode 100644 index 00000000..9fe66370 --- /dev/null +++ b/src/EIP7702/EIP7702BatchDeleGatorProxy.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IBeacon } from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; +import { Proxy } from "@openzeppelin/contracts/proxy/Proxy.sol"; + +/** + * @title EIP7702BatchDeleGatorProxy + * @notice Stable EIP-7702 delegation target for EIP7702BatchDeleGator implementations. + * @dev Users authorize this proxy address once. The beacon address is immutable bytecode data, + * not account storage, so the proxy can safely run from an EIP-7702 delegated EOA. + */ +contract EIP7702BatchDeleGatorProxy is Proxy { + IBeacon private immutable _beacon; + + error InvalidBeacon(address beacon); + error InvalidBeaconImplementation(address implementation); + + constructor(address beacon_) payable { + if (beacon_.code.length == 0) revert InvalidBeacon(beacon_); + + address implementation_ = IBeacon(beacon_).implementation(); + if (implementation_.code.length == 0) revert InvalidBeaconImplementation(implementation_); + + _beacon = IBeacon(beacon_); + } + + /// @notice Returns the immutable beacon used by this delegation proxy. + function beacon() external view returns (address) { + return address(_beacon); + } + + /// @notice Returns the implementation currently selected by the beacon. + function implementation() external view returns (address) { + return _implementation(); + } + + receive() external payable virtual { + _fallback(); + } + + function _implementation() internal view override returns (address implementation_) { + implementation_ = _beacon.implementation(); + if (implementation_.code.length == 0) revert InvalidBeaconImplementation(implementation_); + } +} diff --git a/src/interfaces/IDeleGatorBatchRelayCoordinator.sol b/src/interfaces/IDeleGatorBatchRelayCoordinator.sol new file mode 100644 index 00000000..67e8995f --- /dev/null +++ b/src/interfaces/IDeleGatorBatchRelayCoordinator.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +/** + * @title IDeleGatorBatchRelayCoordinator + * @notice Multi-account coordinator for signed EIP7702BatchDeleGator relay batches. + * @dev Not an EIP-4337 paymaster. Child accounts still require valid signed batches. + */ +interface IDeleGatorBatchRelayCoordinator { + struct AccountBatch { + address account; + bytes32 mode; + bytes executionData; + } + + /// @notice Executes each signed batch row on its delegated account. + function executeBatches(AccountBatch[] calldata batches) external; +} 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/test/DeleGatorBatchRelayCoordinatorTest.t.sol b/test/DeleGatorBatchRelayCoordinatorTest.t.sol new file mode 100644 index 00000000..6a1cf1e9 --- /dev/null +++ b/test/DeleGatorBatchRelayCoordinatorTest.t.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test, Vm } from "forge-std/Test.sol"; + +import { DeleGatorBatchRelayCoordinator } from "../src/DeleGatorBatchRelayCoordinator.sol"; +import { EIP7702BatchDeleGator } from "../src/EIP7702/EIP7702BatchDeleGator.sol"; +import { IDeleGatorBatchRelayCoordinator } from "../src/interfaces/IDeleGatorBatchRelayCoordinator.sol"; +import { Execution } from "../src/utils/Types.sol"; +import { Counter } from "./utils/Counter.t.sol"; +import { BaseTest } from "./utils/BaseTest.t.sol"; + +contract DeleGatorBatchRelayCoordinatorTest is BaseTest { + DeleGatorBatchRelayCoordinator internal coordinator; + EIP7702BatchDeleGator internal implementation; + + uint256 internal accountAPk = 0xA110; + uint256 internal accountBPk = 0xB110; + address internal accountAAddress; + address internal accountBAddress; + + EIP7702BatchDeleGator internal accountA; + EIP7702BatchDeleGator internal accountB; + + Counter internal counterA; + Counter internal counterB; + + uint256 internal constant DEFAULT_DEADLINE = 1 days; + + function setUp() public override { + super.setUp(); + + coordinator = new DeleGatorBatchRelayCoordinator(); + implementation = new EIP7702BatchDeleGator(delegationManager, entryPoint); + + accountAAddress = vm.addr(accountAPk); + accountBAddress = vm.addr(accountBPk); + vm.etch(accountAAddress, address(implementation).code); + vm.etch(accountBAddress, address(implementation).code); + + accountA = EIP7702BatchDeleGator(payable(accountAAddress)); + accountB = EIP7702BatchDeleGator(payable(accountBAddress)); + + counterA = new Counter(accountAAddress); + counterB = new Counter(accountBAddress); + } + + function test_executeBatches_anyCaller() public { + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = + _singleSignedBatch(accountA, accountAPk, counterA, 1, address(coordinator)); + + vm.prank(address(0xBEEF)); + coordinator.executeBatches(batches); + + assertEq(counterA.count(), 1); + } + + function test_executeBatches_unsignedChildRow_recordsFailureAndContinues() public { + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = new IDeleGatorBatchRelayCoordinator.AccountBatch[](2); + + Execution[] memory unsignedCalls = _incrementsFor(counterA, 1); + batches[0] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountAAddress, + mode: accountA.MODE_BATCH_SIMPLE(), + executionData: abi.encode(unsignedCalls) + }); + + Execution[] memory signedCalls = _incrementsFor(counterB, 2); + batches[1] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountBAddress, + mode: accountB.MODE_BATCH_WITH_OPDATA(), + executionData: abi.encode( + signedCalls, + _signedOpData(accountB, accountBPk, signedCalls, 1, address(coordinator)) + ) + }); + + vm.recordLogs(); + coordinator.executeBatches(batches); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 2); + assertEq(logs[0].topics[1], bytes32(uint256(0))); + assertEq(address(uint160(uint256(logs[0].topics[2]))), accountAAddress); + assertFalse(abi.decode(logs[0].data, (bool))); + assertEq(logs[1].topics[1], bytes32(uint256(1))); + assertTrue(abi.decode(logs[1].data, (bool))); + + assertEq(counterA.count(), 0); + assertEq(counterB.count(), 2); + } + + function test_executeBatches_signedRows() public { + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = new IDeleGatorBatchRelayCoordinator.AccountBatch[](2); + + Execution[] memory callsA = _incrementsFor(counterA, 1); + batches[0] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountAAddress, + mode: accountA.MODE_BATCH_WITH_OPDATA(), + executionData: abi.encode( + callsA, _signedOpData(accountA, accountAPk, callsA, 10, address(coordinator)) + ) + }); + + Execution[] memory callsB = _incrementsFor(counterB, 3); + batches[1] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountBAddress, + mode: accountB.MODE_BATCH_WITH_OPDATA(), + executionData: abi.encode( + callsB, _signedOpData(accountB, accountBPk, callsB, 20, address(coordinator)) + ) + }); + + coordinator.executeBatches(batches); + + assertEq(counterA.count(), 1); + assertEq(counterB.count(), 3); + } + + function test_executeBatches_wrongPinnedRelayer_recordsFailure() public { + Execution[] memory calls = _incrementsFor(counterA, 1); + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = new IDeleGatorBatchRelayCoordinator.AccountBatch[](1); + batches[0] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountAAddress, + mode: accountA.MODE_BATCH_WITH_OPDATA(), + executionData: abi.encode(calls, _signedOpData(accountA, accountAPk, calls, 5, address(0xCAFE))) + }); + + vm.recordLogs(); + coordinator.executeBatches(batches); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + assertFalse(abi.decode(logs[0].data, (bool))); + + assertEq(counterA.count(), 0); + } + + function _singleSignedBatch( + EIP7702BatchDeleGator account, + uint256 pk, + Counter counter, + uint256 nonce, + address pinnedRelayer + ) + internal + view + returns (IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches) + { + Execution[] memory calls = _incrementsFor(counter, 1); + batches = new IDeleGatorBatchRelayCoordinator.AccountBatch[](1); + batches[0] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: address(account), + mode: account.MODE_BATCH_WITH_OPDATA(), + executionData: abi.encode(calls, _signedOpData(account, pk, calls, nonce, pinnedRelayer)) + }); + } + + function _incrementsFor(Counter counter, uint256 n) internal pure returns (Execution[] memory executions) { + executions = new Execution[](n); + for (uint256 i = 0; i < n; ++i) { + executions[i] = Execution({ + target: address(counter), value: 0, callData: abi.encodeCall(Counter.unsafeIncrement, ()) + }); + } + } + + function _signedOpData( + EIP7702BatchDeleGator account, + uint256 pk, + Execution[] memory executions, + uint256 nonce, + address pinnedRelayer + ) + internal + view + returns (bytes memory) + { + uint256 deadline = block.timestamp + DEFAULT_DEADLINE; + bytes32 digest = account.hashBatchAuthorizationWithNonce(executions, nonce, deadline, pinnedRelayer); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encode(nonce, deadline, pinnedRelayer, abi.encodePacked(r, s, v)); + } +} diff --git a/test/EIP7702BatchDeleGatorTest.t.sol b/test/EIP7702BatchDeleGatorTest.t.sol new file mode 100644 index 00000000..4b3a760a --- /dev/null +++ b/test/EIP7702BatchDeleGatorTest.t.sol @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { IEntryPoint } from "@account-abstraction/interfaces/IEntryPoint.sol"; + +import { EIP7702BatchDeleGator } from "../src/EIP7702/EIP7702BatchDeleGator.sol"; +import { EIP7702BatchDeleGatorBeacon } from "../src/EIP7702/EIP7702BatchDeleGatorBeacon.sol"; +import { EIP7702BatchDeleGatorProxy } from "../src/EIP7702/EIP7702BatchDeleGatorProxy.sol"; +import { EIP7702DeleGatorCore } from "../src/EIP7702/EIP7702DeleGatorCore.sol"; +import { IDelegationManager } from "../src/interfaces/IDelegationManager.sol"; +import { BatchAuthorizationLib } from "../src/libraries/BatchAuthorizationLib.sol"; +import { Execution, ModeCode } from "../src/utils/Types.sol"; +import { Counter } from "./utils/Counter.t.sol"; +import { BaseTest } from "./utils/BaseTest.t.sol"; +import { StorageUtilsLib } from "./utils/StorageUtilsLib.t.sol"; + +interface IVersionedBatchDeleGator { + function version() external view returns (uint256); +} + +contract RevertingTarget { + error AlwaysReverts(); + + function boom() external pure { + revert AlwaysReverts(); + } +} + +contract EIP7702BatchDeleGatorTest is BaseTest { + using ModeLib for ModeCode; + + EIP7702BatchDeleGator internal implementation; + EIP7702BatchDeleGator internal account; + Counter internal counter; + + uint256 internal accountPk = 0xA11CE; + address internal accountAddress; + address internal relayer = address(0xBEEF); + + uint256 internal constant DEFAULT_DEADLINE = 1 days; + uint256 internal constant DEFAULT_NONCE = 42; + + bytes32 internal modeBatchSimple; + bytes32 internal modeBatchWithOpData; + bytes32 internal modeBatchOfBatches; + + function setUp() public override { + super.setUp(); + + accountAddress = vm.addr(accountPk); + implementation = new EIP7702BatchDeleGator(delegationManager, entryPoint); + vm.etch(accountAddress, address(implementation).code); + account = EIP7702BatchDeleGator(payable(accountAddress)); + counter = new Counter(accountAddress); + + modeBatchSimple = implementation.MODE_BATCH_SIMPLE(); + modeBatchWithOpData = implementation.MODE_BATCH_WITH_OPDATA(); + modeBatchOfBatches = implementation.MODE_BATCH_OF_BATCHES(); + } + + function test_supportsBatchExecutionModes() public { + assertTrue(account.supportsBatchExecutionMode(account.MODE_BATCH_SIMPLE())); + assertTrue(account.supportsBatchExecutionMode(account.MODE_BATCH_WITH_OPDATA())); + assertTrue(account.supportsBatchExecutionMode(account.MODE_BATCH_OF_BATCHES())); + } + + function test_inheritedSupportsExecutionMode_excludesRelayOnlyModes() public { + assertFalse(account.supportsExecutionMode(ModeCode.wrap(account.MODE_BATCH_WITH_OPDATA()))); + assertFalse(account.supportsExecutionMode(ModeCode.wrap(account.MODE_BATCH_OF_BATCHES()))); + } + + function test_executeBatch_unsigned_selfBatch() public { + Execution[] memory executions = _twoIncrements(); + vm.prank(accountAddress); + account.executeBatch(modeBatchSimple, abi.encode(executions)); + assertEq(counter.count(), 2); + } + + function test_executeBatch_unsigned_externalCaller_reverts() public { + Execution[] memory executions = _twoIncrements(); + vm.prank(relayer); + vm.expectRevert(EIP7702BatchDeleGator.UnauthorizedBatchExecuteCaller.selector); + account.executeBatch(modeBatchSimple, abi.encode(executions)); + } + + function test_executeBatch_signed_relay() public { + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0)); + + vm.prank(relayer); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + assertEq(counter.count(), 2); + assertTrue(account.isNonceUsed(DEFAULT_NONCE)); + } + + function test_executeBatch_signed_throughInheritedExecute_revertsForRelayer() public { + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0)); + bytes memory executionData = abi.encode(executions, opData); + + vm.prank(relayer); + vm.expectRevert(EIP7702DeleGatorCore.NotEntryPointOrSelf.selector); + account.execute(ModeCode.wrap(modeBatchWithOpData), executionData); + } + + function test_executeBatch_signed_wrongSigner_reverts() public { + Execution[] memory executions = _twoIncrements(); + bytes32 digest = account.hashBatchAuthorizationWithNonce( + executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBAD, digest); + bytes memory opData = abi.encode(DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0), abi.encodePacked(r, s, v)); + + vm.prank(relayer); + vm.expectRevert(EIP7702BatchDeleGator.InvalidBatchSignature.selector); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + } + + function test_executeBatch_signed_expiredDeadline_reverts() public { + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp - 1, address(0)); + + vm.prank(relayer); + vm.expectRevert(EIP7702BatchDeleGator.BatchAuthorizationExpired.selector); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + } + + function test_executeBatch_signed_wrongRelayer_reverts() public { + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, relayer); + + vm.prank(address(0xCAFE)); + vm.expectRevert(EIP7702BatchDeleGator.UnauthorizedRelayer.selector); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + } + + function test_executeBatch_signed_replay_reverts() public { + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0)); + bytes memory executionData = abi.encode(executions, opData); + + vm.startPrank(relayer); + account.executeBatch(modeBatchWithOpData, executionData); + vm.expectRevert(EIP7702BatchDeleGator.NonceAlreadyUsed.selector); + account.executeBatch(modeBatchWithOpData, executionData); + vm.stopPrank(); + } + + function test_executeBatch_failedExecution_revertsNonceConsumption() public { + RevertingTarget revertingTarget = new RevertingTarget(); + Execution[] memory executions = new Execution[](1); + executions[0] = Execution({ + target: address(revertingTarget), value: 0, callData: abi.encodeCall(RevertingTarget.boom, ()) + }); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0)); + + vm.prank(relayer); + vm.expectRevert(RevertingTarget.AlwaysReverts.selector); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + + assertFalse(account.isNonceUsed(DEFAULT_NONCE)); + } + + function test_invalidateNonce_blocksLaterUse() public { + vm.prank(accountAddress); + account.invalidateNonce(DEFAULT_NONCE); + + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0)); + + vm.prank(relayer); + vm.expectRevert(EIP7702BatchDeleGator.NonceAlreadyUsed.selector); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + } + + function test_invalidateNonces_blocksMaskedNonces() public { + uint256 word = DEFAULT_NONCE >> 8; + uint256 mask = 1 << uint8(DEFAULT_NONCE); + + vm.prank(accountAddress); + account.invalidateNonces(word, mask); + + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0)); + + vm.prank(relayer); + vm.expectRevert(EIP7702BatchDeleGator.NonceAlreadyUsed.selector); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + } + + function test_executeBatch_ofBatches() public { + Execution[] memory executionsA = _twoIncrements(); + Execution[] memory executionsB = _oneIncrement(); + + bytes memory innerA = abi.encode(executionsA, _signedOpData(executionsA, 1, block.timestamp + DEFAULT_DEADLINE, address(0))); + bytes memory innerB = abi.encode(executionsB, _signedOpData(executionsB, 2, block.timestamp + DEFAULT_DEADLINE, address(0))); + + bytes[] memory innerBatches = new bytes[](2); + innerBatches[0] = innerA; + innerBatches[1] = innerB; + + vm.prank(relayer); + account.executeBatch(modeBatchOfBatches, abi.encode(innerBatches)); + + assertEq(counter.count(), 3); + assertTrue(account.isNonceUsed(1)); + assertTrue(account.isNonceUsed(2)); + } + + function test_hashBatchAuthorizationWithNonce_matchesManualDigest() public { + Execution[] memory executions = _twoIncrements(); + uint256 nonce = 7; + uint256 deadline = block.timestamp + DEFAULT_DEADLINE; + + bytes32 callsDigest = BatchAuthorizationLib.executionsDigest(executions); + bytes32 structHash = + BatchAuthorizationLib.batchAuthorizationWithNonceStructHash(callsDigest, nonce, deadline, relayer); + bytes32 expected = MessageHashUtils.toTypedDataHash(account.getDomainHash(), structHash); + + assertEq(account.hashBatchAuthorizationWithNonce(executions, nonce, deadline, relayer), expected); + } + + function test_beaconProxyUpgrade_preservesNonceBitmap() public { + EIP7702BatchDeleGator implementationV1 = new EIP7702BatchDeleGator(delegationManager, entryPoint); + EIP7702BatchDeleGatorV2Mock implementationV2 = new EIP7702BatchDeleGatorV2Mock(delegationManager, entryPoint); + EIP7702BatchDeleGatorBeacon beacon = new EIP7702BatchDeleGatorBeacon(address(implementationV1), address(this)); + EIP7702BatchDeleGatorProxy proxy = new EIP7702BatchDeleGatorProxy(address(beacon)); + + vm.etch(accountAddress, address(proxy).code); + account = EIP7702BatchDeleGator(payable(accountAddress)); + + Execution[] memory executions = _twoIncrements(); + bytes memory opData = _signedOpData(executions, DEFAULT_NONCE, block.timestamp + DEFAULT_DEADLINE, address(0)); + vm.prank(relayer); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, opData)); + assertTrue(account.isNonceUsed(DEFAULT_NONCE)); + + bytes32 nonceSlot = StorageUtilsLib.getStorageLocation("DeleGator.EIP7702BatchDeleGator.nonce"); + uint256 word = DEFAULT_NONCE >> 8; + bytes32 bitmapSlot = keccak256(abi.encode(word, nonceSlot)); + bytes32 bitmapBefore = vm.load(accountAddress, bitmapSlot); + + beacon.upgradeTo(address(implementationV2)); + assertEq(IVersionedBatchDeleGator(accountAddress).version(), 2); + assertEq(vm.load(accountAddress, bitmapSlot), bitmapBefore); + + uint256 nextNonce = DEFAULT_NONCE + 1; + bytes memory nextOpData = + _signedOpData(executions, nextNonce, block.timestamp + DEFAULT_DEADLINE, address(0)); + vm.prank(relayer); + account.executeBatch(modeBatchWithOpData, abi.encode(executions, nextOpData)); + assertEq(counter.count(), 4); + } + + function test_inheritedStandardBatchMode_stillWorks() public { + Execution memory execution = Execution({ + target: address(counter), + value: 0, + callData: abi.encodeCall(Counter.unsafeIncrement, ()) + }); + Execution[] memory executions = new Execution[](1); + executions[0] = execution; + + vm.prank(accountAddress); + account.execute(ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(executions)); + assertEq(counter.count(), 1); + } + + function _twoIncrements() internal view returns (Execution[] memory executions) { + executions = new Execution[](2); + executions[0] = Execution({ + target: address(counter), value: 0, callData: abi.encodeCall(Counter.unsafeIncrement, ()) + }); + executions[1] = Execution({ + target: address(counter), value: 0, callData: abi.encodeCall(Counter.unsafeIncrement, ()) + }); + } + + function _oneIncrement() internal view returns (Execution[] memory executions) { + executions = new Execution[](1); + executions[0] = Execution({ + target: address(counter), value: 0, callData: abi.encodeCall(Counter.unsafeIncrement, ()) + }); + } + + function _signedOpData( + Execution[] memory executions, + uint256 nonce, + uint256 deadline, + address authorizedRelayer + ) + internal + view + returns (bytes memory) + { + bytes32 digest = account.hashBatchAuthorizationWithNonce(executions, nonce, deadline, authorizedRelayer); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountPk, digest); + return abi.encode(nonce, deadline, authorizedRelayer, abi.encodePacked(r, s, v)); + } +} + +contract EIP7702BatchDeleGatorV2Mock is EIP7702BatchDeleGator { + constructor(IDelegationManager delegationManager_, IEntryPoint entryPoint_) + EIP7702BatchDeleGator(delegationManager_, entryPoint_) + { } + + function version() external pure returns (uint256) { + return 2; + } +} diff --git a/test/EIP7702BatchRelayBenchmark.t.sol b/test/EIP7702BatchRelayBenchmark.t.sol new file mode 100644 index 00000000..bf480084 --- /dev/null +++ b/test/EIP7702BatchRelayBenchmark.t.sol @@ -0,0 +1,418 @@ +// 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 { Execution } from "../src/utils/Types.sol"; +import { BasicERC20 } from "./utils/BasicERC20.t.sol"; +import { Counter } from "./utils/Counter.t.sol"; + +import { DeleGatorBatchRelayCoordinator } from "../src/DeleGatorBatchRelayCoordinator.sol"; +import { EIP7702BatchDeleGator } from "../src/EIP7702/EIP7702BatchDeleGator.sol"; +import { IDeleGatorBatchRelayCoordinator } from "../src/interfaces/IDeleGatorBatchRelayCoordinator.sol"; + +/** + * @title EIP7702 Batch Relay Gas Benchmark + * + * @notice Establishes gas baselines for the O1-1/O1-2 batch relay surface on this branch: + * `EIP7702BatchDeleGator.executeBatch` and `DeleGatorBatchRelayCoordinator.executeBatches`. + * + * @dev The three comparable scenarios below execute the SAME on-chain calls as + * `test/OptimizedDelegationManagerBenchmark.t.sol` on `feat/optimized-delegation-manager` + * (baseline / gasless swap / gasless transaction). Only the authorization and dispatch model + * differs: signed batch relay here vs `DelegationManager.redeemDelegations` + caveat enforcers + * there. + * 1. Baseline: `Counter.increment()` on an account-owned counter (single execution). + * 2. Gasless swap: `token.transfer(recipient, 100e18)` (single execution). + * 3. Gasless transaction: `token.transfer(recipient, 50e18)` + `token.transfer(feeAccount, 1e18)`. + * + * @dev WHAT IS MEASURED: We measure ONLY the relayer-facing call frame — either a direct + * `executeBatch(mode, executionData)` on the 7702 account, or `executeBatches(...)` on the + * coordinator — NOT a 4337 UserOp. The EIP-7702 upgrade cost is excluded by construction: + * `vm.etch` installs the batch DeleGator code during `setUp()`. + * For each scenario we report: + * * "execution gas" — gas consumed by the measured call frame (primary KPI). + * * "calldata gas" — EIP-2028 cost of the relayer calldata. + * * "est. tx gas" — 21_000 intrinsic + calldata gas + execution gas (excl. 7702 auth). + * + * @dev MEASUREMENT NOTES + * - Gas is measured with a portable `gasleft()` bracket. Run with `-vv` to print reports. + * - Each signed scenario uses a fresh nonce so the nonce-bitmap SSTORE is a cold 0->1 write. + * - Numbers are a baseline, not a regression gate. + * - For strict per-call isolation: `forge test --isolate --match-contract EIP7702BatchRelayBenchmark`. + */ +contract EIP7702BatchRelayBenchmark is BaseTest { + ////////////////////////////// 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 DEFAULT_DEADLINE = 1 days; + + uint256 internal constant SWAP_AMOUNT = 100e18; + uint256 internal constant SEND_AMOUNT = 50e18; + uint256 internal constant FEE_AMOUNT = 1e18; + + ////////////////////////////// State ////////////////////////////// + + EIP7702BatchDeleGator internal implementation; + EIP7702BatchDeleGator internal account; + DeleGatorBatchRelayCoordinator internal coordinator; + + BasicERC20 internal token; + Counter internal counter; + + uint256 internal accountPk = 0xA11CE; + address internal accountAddress; + address internal relayer = address(0xBEEF); + address internal recipient; + address internal feeAccount; + + bytes32 internal modeBatchSimple; + bytes32 internal modeBatchWithOpData; + bytes32 internal modeBatchOfBatches; + + ////////////////////////////// Set Up ////////////////////////////// + + function setUp() public override { + super.setUp(); + + accountAddress = vm.addr(accountPk); + implementation = new EIP7702BatchDeleGator(delegationManager, entryPoint); + coordinator = new DeleGatorBatchRelayCoordinator(); + vm.etch(accountAddress, address(implementation).code); + account = EIP7702BatchDeleGator(payable(accountAddress)); + + token = new BasicERC20(address(this), "Mock USDC", "USDC", 0); + token.mint(accountAddress, 1_000_000e18); + vm.label(address(token), "MockUSDC"); + + counter = new Counter(accountAddress); + vm.label(address(counter), "Counter"); + + recipient = makeAddr("Recipient"); + feeAccount = makeAddr("MetaMaskFeeAccount"); + + modeBatchSimple = account.MODE_BATCH_SIMPLE(); + modeBatchWithOpData = account.MODE_BATCH_WITH_OPDATA(); + modeBatchOfBatches = account.MODE_BATCH_OF_BATCHES(); + } + + ////////////////////////////// Benchmarks (comparable to OptimizedDelegationManagerBenchmark) ////////////////////////////// + + /// @notice Floor cost: single `Counter.increment()` — same execution as optimized `test_bench_baseline_singleNoCaveats`. + function test_bench_baseline_singleNoCaveats() public { + Execution memory exec = _baselineExecution(); + Execution[] memory executions = _asBatch(exec); + + uint256 before = counter.count(); + _benchExecuteBatch( + "baseline | single execution | no caveats", + accountAddress, + accountAddress, + modeBatchSimple, + abi.encode(executions) + ); + assertEq(counter.count(), before + 1, "counter should increment"); + } + + /// @notice Gasless swap: single `token.transfer(recipient, 100e18)` — same execution as optimized + /// `test_bench_gaslessSwap_singleExecution`. + function test_bench_gaslessSwap_singleExecution() public { + Execution memory exec = _gaslessSwapExecution(); + Execution[] memory executions = _asBatch(exec); + bytes memory executionData = _signedExecutionData(executions, 1, address(0)); + + uint256 before = token.balanceOf(recipient); + _benchExecuteBatch( + "gasless swap | single execution | signed batch relay", + relayer, + accountAddress, + modeBatchWithOpData, + executionData + ); + assertEq(token.balanceOf(recipient), before + SWAP_AMOUNT, "swap proceeds should reach recipient"); + } + + /// @notice Gasless transaction: 2-exec batch (user transfer + fee leg) — same executions as optimized + /// `test_bench_gaslessTransaction_batchTwoExecutions`. + function test_bench_gaslessTransaction_batchTwoExecutions() public { + Execution[] memory executions = _gaslessTransactionExecutions(); + bytes memory executionData = _signedExecutionData(executions, 2, address(0)); + + uint256 recipientBefore = token.balanceOf(recipient); + uint256 feeBefore = token.balanceOf(feeAccount); + _benchExecuteBatch( + "gasless transaction | 2-exec batch | signed batch relay", + relayer, + accountAddress, + modeBatchWithOpData, + executionData + ); + 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 Nested signed batches: two inner signed rows in one outer relay call. + function test_bench_signedRelay_batchOfBatches() public { + Execution[] memory executionsA = _counterIncrements(2); + Execution[] memory executionsB = _counterIncrements(1); + + bytes memory innerA = abi.encode( + executionsA, _signedOpData(executionsA, 10, block.timestamp + DEFAULT_DEADLINE, address(0)) + ); + bytes memory innerB = abi.encode( + executionsB, _signedOpData(executionsB, 11, block.timestamp + DEFAULT_DEADLINE, address(0)) + ); + + bytes[] memory innerBatches = new bytes[](2); + innerBatches[0] = innerA; + innerBatches[1] = innerB; + + _benchExecuteBatch( + "signed relay | MODE_BATCH_OF_BATCHES | 2 inner signed batches", + relayer, + accountAddress, + modeBatchOfBatches, + abi.encode(innerBatches) + ); + + assertEq(counter.count(), 3); + assertTrue(account.isNonceUsed(10)); + assertTrue(account.isNonceUsed(11)); + } + + /// @notice Coordinator: single signed account row relayed through `executeBatches`. + function test_bench_coordinator_singleSignedRow() public { + bytes memory swapCallData = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + Execution[] memory executions = _singleExecution(address(token), swapCallData); + + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = + _coordinatorBatch(executions, 20, address(coordinator)); + + uint256 before = token.balanceOf(recipient); + _benchExecuteBatches("coordinator | 1 signed row", relayer, batches); + assertEq(token.balanceOf(recipient), before + SWAP_AMOUNT); + } + + /// @notice Coordinator: two signed account rows in one permissionless `executeBatches` call. + function test_bench_coordinator_twoSignedRows() public { + uint256 accountBPk = 0xB110; + address accountBAddress = vm.addr(accountBPk); + vm.etch(accountBAddress, address(implementation).code); + EIP7702BatchDeleGator accountB = EIP7702BatchDeleGator(payable(accountBAddress)); + token.mint(accountBAddress, 1_000_000e18); + + bytes memory swapCallData = abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT); + + Execution[] memory executionsA = _singleExecution(address(token), swapCallData); + Execution[] memory executionsB = _singleExecution(address(token), swapCallData); + + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = + new IDeleGatorBatchRelayCoordinator.AccountBatch[](2); + batches[0] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountAddress, + mode: modeBatchWithOpData, + executionData: abi.encode( + executionsA, _signedOpData(executionsA, 30, block.timestamp + DEFAULT_DEADLINE, address(coordinator)) + ) + }); + batches[1] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountBAddress, + mode: accountB.MODE_BATCH_WITH_OPDATA(), + executionData: abi.encode( + executionsB, + _signedOpDataFor(accountB, accountBPk, executionsB, 31, block.timestamp + DEFAULT_DEADLINE, address(coordinator)) + ) + }); + + uint256 before = token.balanceOf(recipient); + _benchExecuteBatches("coordinator | 2 signed rows", relayer, batches); + assertEq(token.balanceOf(recipient), before + SWAP_AMOUNT + SWAP_AMOUNT); + } + + ////////////////////////////// Internal: execution builders (mirrors OptimizedDelegationManagerBenchmark) ////////////////////////////// + + /// @dev Matches optimized `test_bench_baseline_singleNoCaveats`: account-owned counter, `increment()`. + function _baselineExecution() internal view returns (Execution memory exec) { + exec = Execution({ + target: address(counter), value: 0, callData: abi.encodeWithSelector(Counter.increment.selector) + }); + } + + /// @dev Matches optimized `test_bench_gaslessSwap_singleExecution`: ERC-20 transfer of SWAP_AMOUNT. + function _gaslessSwapExecution() internal view returns (Execution memory exec) { + exec = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, recipient, SWAP_AMOUNT) + }); + } + + /// @dev Matches optimized `test_bench_gaslessTransaction_batchTwoExecutions`: user leg + fee leg. + function _gaslessTransactionExecutions() internal view returns (Execution[] memory executions) { + 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) + }); + } + + function _asBatch(Execution memory exec) internal pure returns (Execution[] memory executions) { + executions = new Execution[](1); + executions[0] = exec; + } + + function _counterIncrements(uint256 n) internal view returns (Execution[] memory executions) { + executions = new Execution[](n); + for (uint256 i = 0; i < n; ++i) { + executions[i] = Execution({ + target: address(counter), value: 0, callData: abi.encodeCall(Counter.unsafeIncrement, ()) + }); + } + } + + function _singleExecution(address target, bytes memory callData) internal pure returns (Execution[] memory executions) { + executions = new Execution[](1); + executions[0] = Execution({ target: target, value: 0, callData: callData }); + } + + function _signedExecutionData( + Execution[] memory executions, + uint256 nonce, + address pinnedRelayer + ) + internal + view + returns (bytes memory) + { + return abi.encode(executions, _signedOpData(executions, nonce, block.timestamp + DEFAULT_DEADLINE, pinnedRelayer)); + } + + function _signedOpData( + Execution[] memory executions, + uint256 nonce, + uint256 deadline, + address pinnedRelayer + ) + internal + view + returns (bytes memory) + { + return _signedOpDataFor(account, accountPk, executions, nonce, deadline, pinnedRelayer); + } + + function _signedOpDataFor( + EIP7702BatchDeleGator batchAccount, + uint256 pk, + Execution[] memory executions, + uint256 nonce, + uint256 deadline, + address pinnedRelayer + ) + internal + view + returns (bytes memory) + { + bytes32 digest = batchAccount.hashBatchAuthorizationWithNonce(executions, nonce, deadline, pinnedRelayer); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encode(nonce, deadline, pinnedRelayer, abi.encodePacked(r, s, v)); + } + + function _coordinatorBatch( + Execution[] memory executions, + uint256 nonce, + address pinnedRelayer + ) + internal + view + returns (IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches) + { + batches = new IDeleGatorBatchRelayCoordinator.AccountBatch[](1); + batches[0] = IDeleGatorBatchRelayCoordinator.AccountBatch({ + account: accountAddress, + mode: modeBatchWithOpData, + executionData: abi.encode( + executions, _signedOpData(executions, nonce, block.timestamp + DEFAULT_DEADLINE, pinnedRelayer) + ) + }); + } + + ////////////////////////////// Internal: measurement ////////////////////////////// + + function _benchExecuteBatch( + string memory label, + address caller, + address batchAccount, + bytes32 mode, + bytes memory executionData + ) + internal + { + bytes memory callData = abi.encodeWithSelector(EIP7702BatchDeleGator.executeBatch.selector, mode, executionData); + + vm.prank(caller); + uint256 gasBefore = gasleft(); + (bool ok, bytes memory ret) = batchAccount.call(callData); + uint256 executionGas = gasBefore - gasleft(); + + if (!ok) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + + _report(label, executionGas, callData); + } + + function _benchExecuteBatches( + string memory label, + address caller, + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches + ) + internal + { + bytes memory callData = abi.encodeWithSelector(DeleGatorBatchRelayCoordinator.executeBatches.selector, batches); + + vm.prank(caller); + uint256 gasBefore = gasleft(); + (bool ok, bytes memory ret) = address(coordinator).call(callData); + uint256 executionGas = gasBefore - gasleft(); + + if (!ok) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + + _report(label, executionGas, callData); + } + + function _report(string memory label, uint256 executionGas, bytes memory callData) internal view { + uint256 calldataGas = _calldataGas(callData); + uint256 estTxGas = INTRINSIC_GAS + calldataGas + executionGas; + + console.log("====================================================================="); + console.log(label); + console.log(string.concat(" execution gas ..................... ", vm.toString(executionGas))); + console.log(string.concat(" calldata size (bytes) ............. ", vm.toString(callData.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("====================================================================="); + } + + 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; + } + } +}