From a5efcd6397e5484d373b309bbd0b933c251c6f54 Mon Sep 17 00:00:00 2001 From: nthpool Date: Tue, 9 Jun 2026 15:27:04 -0400 Subject: [PATCH 1/2] Add EIP7702 batch DeleGator relay surface and coordinator contracts. Introduce signed executeBatch relay execution with nonce replay protection, optional beacon proxy delegation target, batch relay coordinator, deploy script, and tests per O1-1/O1-2. Co-authored-by: Cursor --- script/DeployEIP7702BatchDeleGator.s.sol | 93 ++++++ src/DeleGatorBatchRelayCoordinator.sol | 44 +++ src/EIP7702/EIP7702BatchDeleGator.sol | 306 +++++++++++++++++ src/EIP7702/EIP7702BatchDeleGatorBeacon.sol | 9 + src/EIP7702/EIP7702BatchDeleGatorProxy.sol | 46 +++ .../IDeleGatorBatchRelayCoordinator.sol | 18 + src/interfaces/IEIP7702BatchDeleGator.sol | 59 ++++ src/libraries/BatchAuthorizationLib.sol | 111 +++++++ test/DeleGatorBatchRelayCoordinatorTest.t.sol | 189 +++++++++++ test/EIP7702BatchDeleGatorTest.t.sol | 314 ++++++++++++++++++ 10 files changed, 1189 insertions(+) create mode 100644 script/DeployEIP7702BatchDeleGator.s.sol create mode 100644 src/DeleGatorBatchRelayCoordinator.sol create mode 100644 src/EIP7702/EIP7702BatchDeleGator.sol create mode 100644 src/EIP7702/EIP7702BatchDeleGatorBeacon.sol create mode 100644 src/EIP7702/EIP7702BatchDeleGatorProxy.sol create mode 100644 src/interfaces/IDeleGatorBatchRelayCoordinator.sol create mode 100644 src/interfaces/IEIP7702BatchDeleGator.sol create mode 100644 src/libraries/BatchAuthorizationLib.sol create mode 100644 test/DeleGatorBatchRelayCoordinatorTest.t.sol create mode 100644 test/EIP7702BatchDeleGatorTest.t.sol diff --git a/script/DeployEIP7702BatchDeleGator.s.sol b/script/DeployEIP7702BatchDeleGator.s.sol new file mode 100644 index 00000000..fe467c19 --- /dev/null +++ b/script/DeployEIP7702BatchDeleGator.s.sol @@ -0,0 +1,93 @@ +// 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) + * - COORDINATOR_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; + address internal coordinatorOwner; + 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); + coordinatorOwner = vm.envOr("COORDINATOR_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("Coordinator Owner: %s", coordinatorOwner); + 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 }(coordinatorOwner)); + 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..330664f3 --- /dev/null +++ b/src/DeleGatorBatchRelayCoordinator.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { IDeleGatorBatchRelayCoordinator } from "./interfaces/IDeleGatorBatchRelayCoordinator.sol"; +import { IEIP7702BatchDeleGator } from "./interfaces/IEIP7702BatchDeleGator.sol"; + +/** + * @title DeleGatorBatchRelayCoordinator + * @notice Owner-gated 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 Ownable, 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); + + constructor(address initialOwner) Ownable(initialOwner) { } + + /// @inheritdoc IDeleGatorBatchRelayCoordinator + function executeBatches(AccountBatch[] calldata batches) external onlyOwner { + 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..86b9831e --- /dev/null +++ b/test/DeleGatorBatchRelayCoordinatorTest.t.sol @@ -0,0 +1,189 @@ +// 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 ownerPk = 0xA11CE; + address internal owner; + + 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(); + + owner = vm.addr(ownerPk); + coordinator = new DeleGatorBatchRelayCoordinator(owner); + 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_onlyOwner() public { + IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = + _singleSignedBatch(accountA, accountAPk, counterA, 1, address(0)); + + vm.expectRevert(); + coordinator.executeBatches(batches); + } + + 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(); + vm.prank(owner); + 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)) + ) + }); + + vm.prank(owner); + 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(); + vm.prank(owner); + 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; + } +} From 9414bea26c5d70c6eec662b1e1bd3f7369486793 Mon Sep 17 00:00:00 2001 From: nthpool Date: Wed, 10 Jun 2026 16:32:18 -0400 Subject: [PATCH 2/2] Refactor DeleGatorBatchRelayCoordinator to remove owner restrictions and simplify deployment. Update deployment script to eliminate unnecessary parameters and adjust test cases for permissionless execution. --- script/DeployEIP7702BatchDeleGator.s.sol | 6 +----- src/DeleGatorBatchRelayCoordinator.sol | 10 +++------- test/DeleGatorBatchRelayCoordinatorTest.t.sol | 17 ++++++----------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/script/DeployEIP7702BatchDeleGator.s.sol b/script/DeployEIP7702BatchDeleGator.s.sol index fe467c19..077bc765 100644 --- a/script/DeployEIP7702BatchDeleGator.s.sol +++ b/script/DeployEIP7702BatchDeleGator.s.sol @@ -21,7 +21,6 @@ import { IDelegationManager } from "../src/interfaces/IDelegationManager.sol"; * - DELEGATION_MANAGER_ADDRESS * @dev Optional env: * - BEACON_OWNER (defaults to deployer) - * - COORDINATOR_OWNER (defaults to deployer) * - DEPLOY_PROXY=true|false (defaults to true) */ contract DeployEIP7702BatchDeleGator is Script { @@ -30,7 +29,6 @@ contract DeployEIP7702BatchDeleGator is Script { IDelegationManager internal delegationManager; address internal deployer; address internal beaconOwner; - address internal coordinatorOwner; bool internal deployProxy; function setUp() public { @@ -39,7 +37,6 @@ contract DeployEIP7702BatchDeleGator is Script { delegationManager = IDelegationManager(vm.envAddress("DELEGATION_MANAGER_ADDRESS")); deployer = msg.sender; beaconOwner = vm.envOr("BEACON_OWNER", deployer); - coordinatorOwner = vm.envOr("COORDINATOR_OWNER", deployer); deployProxy = vm.envOr("DEPLOY_PROXY", true); console2.log("~~~ DeployEIP7702BatchDeleGator ~~~"); @@ -47,7 +44,6 @@ contract DeployEIP7702BatchDeleGator is Script { console2.log("Entry Point: %s", address(entryPoint)); console2.log("Delegation Manager: %s", address(delegationManager)); console2.log("Beacon Owner: %s", beaconOwner); - console2.log("Coordinator Owner: %s", coordinatorOwner); console2.log("Deploy Proxy: %s", deployProxy); console2.log("Salt:"); console2.logBytes32(salt); @@ -75,7 +71,7 @@ contract DeployEIP7702BatchDeleGator is Script { authorizationTarget = proxy; } - address coordinator = address(new DeleGatorBatchRelayCoordinator{ salt: salt }(coordinatorOwner)); + address coordinator = address(new DeleGatorBatchRelayCoordinator{ salt: salt }()); console2.log("DeleGatorBatchRelayCoordinator: %s", coordinator); console2.log("~~~ Release Metadata ~~~"); diff --git a/src/DeleGatorBatchRelayCoordinator.sol b/src/DeleGatorBatchRelayCoordinator.sol index 330664f3..709f73b0 100644 --- a/src/DeleGatorBatchRelayCoordinator.sol +++ b/src/DeleGatorBatchRelayCoordinator.sol @@ -1,27 +1,23 @@ // SPDX-License-Identifier: MIT AND Apache-2.0 pragma solidity 0.8.23; -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; - import { IDeleGatorBatchRelayCoordinator } from "./interfaces/IDeleGatorBatchRelayCoordinator.sol"; import { IEIP7702BatchDeleGator } from "./interfaces/IEIP7702BatchDeleGator.sol"; /** * @title DeleGatorBatchRelayCoordinator - * @notice Owner-gated multi-account coordinator for signed batch DeleGator relay execution. + * @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 Ownable, IDeleGatorBatchRelayCoordinator { +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); - constructor(address initialOwner) Ownable(initialOwner) { } - /// @inheritdoc IDeleGatorBatchRelayCoordinator - function executeBatches(AccountBatch[] calldata batches) external onlyOwner { + function executeBatches(AccountBatch[] calldata batches) external { uint256 len = batches.length; for (uint256 i = 0; i < len;) { AccountBatch calldata batch = batches[i]; diff --git a/test/DeleGatorBatchRelayCoordinatorTest.t.sol b/test/DeleGatorBatchRelayCoordinatorTest.t.sol index 86b9831e..6a1cf1e9 100644 --- a/test/DeleGatorBatchRelayCoordinatorTest.t.sol +++ b/test/DeleGatorBatchRelayCoordinatorTest.t.sol @@ -14,9 +14,6 @@ contract DeleGatorBatchRelayCoordinatorTest is BaseTest { DeleGatorBatchRelayCoordinator internal coordinator; EIP7702BatchDeleGator internal implementation; - uint256 internal ownerPk = 0xA11CE; - address internal owner; - uint256 internal accountAPk = 0xA110; uint256 internal accountBPk = 0xB110; address internal accountAAddress; @@ -33,8 +30,7 @@ contract DeleGatorBatchRelayCoordinatorTest is BaseTest { function setUp() public override { super.setUp(); - owner = vm.addr(ownerPk); - coordinator = new DeleGatorBatchRelayCoordinator(owner); + coordinator = new DeleGatorBatchRelayCoordinator(); implementation = new EIP7702BatchDeleGator(delegationManager, entryPoint); accountAAddress = vm.addr(accountAPk); @@ -49,12 +45,14 @@ contract DeleGatorBatchRelayCoordinatorTest is BaseTest { counterB = new Counter(accountBAddress); } - function test_executeBatches_onlyOwner() public { + function test_executeBatches_anyCaller() public { IDeleGatorBatchRelayCoordinator.AccountBatch[] memory batches = - _singleSignedBatch(accountA, accountAPk, counterA, 1, address(0)); + _singleSignedBatch(accountA, accountAPk, counterA, 1, address(coordinator)); - vm.expectRevert(); + vm.prank(address(0xBEEF)); coordinator.executeBatches(batches); + + assertEq(counterA.count(), 1); } function test_executeBatches_unsignedChildRow_recordsFailureAndContinues() public { @@ -78,7 +76,6 @@ contract DeleGatorBatchRelayCoordinatorTest is BaseTest { }); vm.recordLogs(); - vm.prank(owner); coordinator.executeBatches(batches); Vm.Log[] memory logs = vm.getRecordedLogs(); @@ -114,7 +111,6 @@ contract DeleGatorBatchRelayCoordinatorTest is BaseTest { ) }); - vm.prank(owner); coordinator.executeBatches(batches); assertEq(counterA.count(), 1); @@ -131,7 +127,6 @@ contract DeleGatorBatchRelayCoordinatorTest is BaseTest { }); vm.recordLogs(); - vm.prank(owner); coordinator.executeBatches(batches); Vm.Log[] memory logs = vm.getRecordedLogs();