From 15b9d4b9ec9ebb6770157aa123e9f146e033f21d Mon Sep 17 00:00:00 2001 From: Ryan <81343914+McOso@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:56:38 -0700 Subject: [PATCH] feat: add batch specific enforcer --- ...ecificActionERC20TransferBatchEnforcer.sol | 132 ++++++++++++++ test/DeleGatorTestSuite.t.sol | 75 ++++++++ ...ificActionERC20TransferBatchEnforcer.t.sol | 171 ++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 src/enforcers/SpecificActionERC20TransferBatchEnforcer.sol create mode 100644 test/enforcers/SpecificActionERC20TransferBatchEnforcer.t.sol diff --git a/src/enforcers/SpecificActionERC20TransferBatchEnforcer.sol b/src/enforcers/SpecificActionERC20TransferBatchEnforcer.sol new file mode 100644 index 0000000..b44bb62 --- /dev/null +++ b/src/enforcers/SpecificActionERC20TransferBatchEnforcer.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode, Execution } from "../utils/Types.sol"; + +/** + * @title SpecificActionERC20TransferBatchEnforcer + * @dev This enforcer validates a batch of exactly 2 transactions where: + * 1. First transaction must match specific target, method and calldata + * 2. Second transaction must be an ERC20 transfer with specific parameters + * @dev The delegation can only be executed once + */ +contract SpecificActionERC20TransferBatchEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + using ModeLib for ModeCode; + + ////////////////////////////// State ////////////////////////////// + + // Tracks if a delegation has been executed + mapping(address delegationManager => mapping(bytes32 delegationHash => bool used)) public usedDelegations; + + ////////////////////////////// Events ////////////////////////////// + + event DelegationExecuted(address indexed delegationManager, bytes32 indexed delegationHash); + + ////////////////////////////// Structs ////////////////////////////// + + struct TermsData { + address tokenAddress; + address recipient; + uint256 amount; + address firstTarget; + bytes firstCalldata; + } + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Enforces the batch execution rules + * @param _terms The encoded terms containing: + * - ERC20 token address (20 bytes) + * - Transfer recipient address (20 bytes) + * - Transfer amount (32 bytes) + * - First transaction target address (20 bytes) + * - First transaction calldata (remaining bytes) + * @param _mode The execution mode + * @param _executionCallData The batch execution calldata + * @param _delegationHash The delegation hash + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address + ) + public + override + onlyBatchExecutionMode(_mode) + { + // Check delegation hasn't been used + if (usedDelegations[msg.sender][_delegationHash]) { + revert("SpecificActionERC20TransferBatchEnforcer:delegation-already-used"); + } + + // Mark delegation as used + usedDelegations[msg.sender][_delegationHash] = true; + + // Decode the batch executions + Execution[] calldata executions_ = _executionCallData.decodeBatch(); + + // Validate batch size + if (executions_.length != 2) { + revert("SpecificActionERC20TransferBatchEnforcer:invalid-batch-size"); + } + + // Decode terms into struct + TermsData memory terms_ = getTermsInfo(_terms); + + // Validate first transaction + if ( + executions_[0].target != terms_.firstTarget || executions_[0].value != 0 + || keccak256(executions_[0].callData) != keccak256(terms_.firstCalldata) + ) { + revert("SpecificActionERC20TransferBatchEnforcer:invalid-first-transaction"); + } + + // Validate second transaction + if ( + executions_[1].target != terms_.tokenAddress || executions_[1].value != 0 || executions_[1].callData.length != 68 + || bytes4(executions_[1].callData[0:4]) != IERC20.transfer.selector + || address(uint160(uint256(bytes32(executions_[1].callData[4:36])))) != terms_.recipient + || uint256(bytes32(executions_[1].callData[36:68])) != terms_.amount + ) { + revert("SpecificActionERC20TransferBatchEnforcer:invalid-second-transaction"); + } + + emit DelegationExecuted(msg.sender, _delegationHash); + } + + /** + * @notice Decodes the terms used in this CaveatEnforcer + * @param _terms The encoded terms + * @return termsData_ The decoded terms data + */ + function getTermsInfo(bytes calldata _terms) public pure returns (TermsData memory termsData_) { + // Require minimum length: 20 + 20 + 32 + 20 = 92 bytes + require(_terms.length >= 92, "SpecificActionERC20TransferBatchEnforcer:invalid-terms-length"); + + // First 20 bytes is token address + termsData_.tokenAddress = address(bytes20(_terms[0:20])); + + // Next 20 bytes is recipient address + termsData_.recipient = address(bytes20(_terms[20:40])); + + // Next 32 bytes is amount + termsData_.amount = uint256(bytes32(_terms[40:72])); + + // Next 20 bytes is first target + termsData_.firstTarget = address(bytes20(_terms[72:92])); + + // Remaining bytes is firstCalldata + termsData_.firstCalldata = _terms[92:]; + } +} diff --git a/test/DeleGatorTestSuite.t.sol b/test/DeleGatorTestSuite.t.sol index 9d33ea0..f44b6b0 100644 --- a/test/DeleGatorTestSuite.t.sol +++ b/test/DeleGatorTestSuite.t.sol @@ -39,6 +39,8 @@ import { DeleGatorCore } from "../src/DeleGatorCore.sol"; import { EncoderLib } from "../src/libraries/EncoderLib.sol"; import { AllowedMethodsEnforcer } from "../src/enforcers/AllowedMethodsEnforcer.sol"; import { AllowedTargetsEnforcer } from "../src/enforcers/AllowedTargetsEnforcer.sol"; +import { SpecificActionERC20TransferBatchEnforcer } from "../src/enforcers/SpecificActionERC20TransferBatchEnforcer.sol"; +import { BasicERC20, IERC20 } from "./utils/BasicERC20.t.sol"; abstract contract DeleGatorTestSuite is BaseTest { using ModeLib for ModeCode; @@ -49,6 +51,7 @@ abstract contract DeleGatorTestSuite is BaseTest { Counter aliceDeleGatorCounter; Counter bobDeleGatorCounter; ModeCode[] oneSingularMode; + ModeCode[] oneBatchMode; function setUp() public virtual override { super.setUp(); @@ -57,18 +60,28 @@ abstract contract DeleGatorTestSuite is BaseTest { vm.label(address(allowedMethodsEnforcer), "Allowed Methods Enforcer"); allowedTargetsEnforcer = new AllowedTargetsEnforcer(); vm.label(address(allowedTargetsEnforcer), "Allowed Targets Enforcer"); + specificActionEnforcer = new SpecificActionERC20TransferBatchEnforcer(); + vm.label(address(specificActionEnforcer), "Specific Action ERC20 Transfer Batch Enforcer"); aliceDeleGatorCounter = new Counter(address(users.alice.deleGator)); bobDeleGatorCounter = new Counter(address(users.bob.deleGator)); + token = new BasicERC20(address(users.alice.deleGator), "Test", "TST", 100 ether); oneSingularMode = new ModeCode[](1); oneSingularMode[0] = ModeLib.encodeSimpleSingle(); + + // Set up batch mode + oneBatchMode = new ModeCode[](1); + oneBatchMode[0] = ModeLib.encode(CALLTYPE_BATCH, EXECTYPE_DEFAULT, MODE_DEFAULT, ModePayload.wrap(0x00)); } ////////////////////////////// State ////////////////////////////// AllowedMethodsEnforcer public allowedMethodsEnforcer; AllowedTargetsEnforcer public allowedTargetsEnforcer; + SpecificActionERC20TransferBatchEnforcer public specificActionEnforcer; + BasicERC20 public token; + uint256 public constant TRANSFER_AMOUNT = 10 ether; bytes32 private DELEGATIONS_STORAGE_LOCATION = StorageUtilsLib.getStorageLocation("DeleGator.Delegations"); bytes32 private DELEGATOR_CORE_STORAGE_LOCATION = StorageUtilsLib.getStorageLocation("DeleGator.Core"); bytes32 private INITIALIZABLE_STORAGE_LOCATION = StorageUtilsLib.getStorageLocation("openzeppelin.storage.Initializable"); @@ -2242,6 +2255,68 @@ abstract contract DeleGatorTestSuite is BaseTest { ); entryPoint.handleOps(userOps_, bundler); } + + // should allow a specific action ERC20 transfer batch through delegation + function test_allow_specificActionERC20TransferBatch() public { + // Create batch of executions + Execution[] memory executions = new Execution[](2); + + // First execution: increment counter + bytes memory incrementCalldata = abi.encodeWithSelector(Counter.increment.selector); + executions[0] = Execution({ target: address(aliceDeleGatorCounter), value: 0, callData: incrementCalldata }); + + // Second execution: transfer tokens + executions[1] = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, users.bob.addr, TRANSFER_AMOUNT) + }); + + // Create matching terms + bytes memory terms = abi.encodePacked( + address(token), // tokenAddress + users.bob.addr, // recipient + TRANSFER_AMOUNT, // amount + address(aliceDeleGatorCounter), // firstTarget + incrementCalldata // firstCalldata + ); + + // Create delegation from Alice to Bob with the SpecificActionERC20TransferBatchEnforcer caveat + Caveat[] memory caveats = new Caveat[](1); + caveats[0] = Caveat({ enforcer: address(specificActionEnforcer), terms: terms, args: hex"" }); + + Delegation memory delegation = Delegation({ + delegate: users.bob.addr, + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats, + salt: 0, + signature: hex"" + }); + + delegation = signDelegation(users.alice, delegation); + + // Record initial states + uint256 initialCount = aliceDeleGatorCounter.count(); + uint256 initialBalance = token.balanceOf(users.bob.addr); + + // Prepare delegation redemption parameters + bytes[] memory permissionContexts = new bytes[](1); + Delegation[] memory delegations = new Delegation[](1); + delegations[0] = delegation; + permissionContexts[0] = abi.encode(delegations); + + bytes[] memory executionCallDatas = new bytes[](1); + executionCallDatas[0] = ExecutionLib.encodeBatch(executions); + + // Bob redeems the delegation to execute the batch + vm.prank(users.bob.addr); + delegationManager.redeemDelegations(permissionContexts, oneBatchMode, executionCallDatas); + + // Verify states changed correctly + assertEq(aliceDeleGatorCounter.count(), initialCount + 1); + assertEq(token.balanceOf(users.bob.addr), initialBalance + TRANSFER_AMOUNT); + } } abstract contract UUPSDeleGatorTest is DeleGatorTestSuite { diff --git a/test/enforcers/SpecificActionERC20TransferBatchEnforcer.t.sol b/test/enforcers/SpecificActionERC20TransferBatchEnforcer.t.sol new file mode 100644 index 0000000..9ce0037 --- /dev/null +++ b/test/enforcers/SpecificActionERC20TransferBatchEnforcer.t.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { Execution, Caveat, Delegation, ModeCode } from "../../src/utils/Types.sol"; +import { Counter } from "../utils/Counter.t.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { SpecificActionERC20TransferBatchEnforcer } from "../../src/enforcers/SpecificActionERC20TransferBatchEnforcer.sol"; +import { IDelegationManager } from "../../src/interfaces/IDelegationManager.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { BasicERC20, IERC20 } from "../utils/BasicERC20.t.sol"; + +contract SpecificActionERC20TransferBatchEnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + ////////////////////// State ////////////////////// + + SpecificActionERC20TransferBatchEnforcer public batchEnforcer; + BasicERC20 public token; + ModeCode public batchMode = ModeLib.encodeSimpleBatch(); + ModeCode public singleMode = ModeLib.encodeSimpleSingle(); + uint256 public constant TRANSFER_AMOUNT = 10 ether; + + ////////////////////// Set up ////////////////////// + + function setUp() public override { + super.setUp(); + batchEnforcer = new SpecificActionERC20TransferBatchEnforcer(); + token = new BasicERC20(address(users.alice.deleGator), "Test", "TST", 100 ether); + vm.label(address(batchEnforcer), "Specific Action ERC20 Transfer Batch Enforcer"); + } + + ////////////////////// Valid cases ////////////////////// + + // should allow a valid batch execution with correct parameters + function test_validBatchExecution() public { + (Execution[] memory executions, bytes memory terms) = _setupValidBatchAndTerms(); + bytes memory executionCallData = ExecutionLib.encodeBatch(executions); + + vm.prank(address(delegationManager)); + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, keccak256("test"), address(0), address(0)); + } + + // should allow multiple different delegations with same parameters + function test_multipleDelegationsAllowed() public { + (Execution[] memory executions, bytes memory terms) = _setupValidBatchAndTerms(); + bytes memory executionCallData = ExecutionLib.encodeBatch(executions); + + vm.startPrank(address(delegationManager)); + + // First delegation + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, keccak256("delegation1"), address(0), address(0)); + + // Second delegation with different hash + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, keccak256("delegation2"), address(0), address(0)); + + vm.stopPrank(); + } + + ////////////////////// Invalid cases ////////////////////// + + // should fail with invalid mode (single mode instead of batch) + function test_revertWithInvalidMode() public { + (Execution[] memory executions, bytes memory terms) = _setupValidBatchAndTerms(); + bytes memory executionCallData = ExecutionLib.encodeBatch(executions); + + vm.prank(address(delegationManager)); + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + batchEnforcer.beforeHook(terms, hex"", singleMode, executionCallData, keccak256("test"), address(0), address(0)); + } + + // should fail when trying to reuse a delegation + function test_revertOnDelegationReuse() public { + (Execution[] memory executions, bytes memory terms) = _setupValidBatchAndTerms(); + bytes memory executionCallData = ExecutionLib.encodeBatch(executions); + bytes32 delegationHash = keccak256("test"); + + vm.startPrank(address(delegationManager)); + + // First use + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, delegationHash, address(0), address(0)); + + // Attempt reuse + vm.expectRevert("SpecificActionERC20TransferBatchEnforcer:delegation-already-used"); + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, delegationHash, address(0), address(0)); + + vm.stopPrank(); + } + + // should fail with invalid batch size + function test_revertWithInvalidBatchSize() public { + Execution[] memory executions = new Execution[](1); + executions[0] = Execution({ + target: address(aliceDeleGatorCounter), + value: 0, + callData: abi.encodeWithSelector(Counter.increment.selector) + }); + + (, bytes memory terms) = _setupValidBatchAndTerms(); + bytes memory executionCallData = ExecutionLib.encodeBatch(executions); + + vm.prank(address(delegationManager)); + vm.expectRevert("SpecificActionERC20TransferBatchEnforcer:invalid-batch-size"); + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, keccak256("test"), address(0), address(0)); + } + + // should fail with invalid first transaction + function test_revertWithInvalidFirstTransaction() public { + (Execution[] memory executions, bytes memory terms) = _setupValidBatchAndTerms(); + // Modify first transaction + executions[0].target = address(token); + bytes memory executionCallData = ExecutionLib.encodeBatch(executions); + + vm.prank(address(delegationManager)); + vm.expectRevert("SpecificActionERC20TransferBatchEnforcer:invalid-first-transaction"); + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, keccak256("test"), address(0), address(0)); + } + + // should fail with invalid second transaction + function test_revertWithInvalidSecondTransaction() public { + (Execution[] memory executions, bytes memory terms) = _setupValidBatchAndTerms(); + // Modify second transaction amount + executions[1].callData = abi.encodeWithSelector(IERC20.transfer.selector, users.bob.addr, TRANSFER_AMOUNT + 1); + bytes memory executionCallData = ExecutionLib.encodeBatch(executions); + + vm.prank(address(delegationManager)); + vm.expectRevert("SpecificActionERC20TransferBatchEnforcer:invalid-second-transaction"); + batchEnforcer.beforeHook(terms, hex"", batchMode, executionCallData, keccak256("test"), address(0), address(0)); + } + + // should fail with invalid terms length + function test_revertWithInvalidTermsLength() public { + vm.expectRevert("SpecificActionERC20TransferBatchEnforcer:invalid-terms-length"); + batchEnforcer.getTermsInfo(new bytes(91)); // Minimum required is 92 bytes + } + + ////////////////////// Helper functions ////////////////////// + + function _setupValidBatchAndTerms() internal view returns (Execution[] memory executions, bytes memory terms) { + // Create valid batch of executions + executions = new Execution[](2); + + // First execution: increment counter + bytes memory incrementCalldata = abi.encodeWithSelector(Counter.increment.selector); + executions[0] = Execution({ target: address(aliceDeleGatorCounter), value: 0, callData: incrementCalldata }); + + // Second execution: transfer tokens + executions[1] = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, users.bob.addr, TRANSFER_AMOUNT) + }); + + // Create matching terms + terms = abi.encodePacked( + address(token), // tokenAddress + users.bob.addr, // recipient + TRANSFER_AMOUNT, // amount + address(aliceDeleGatorCounter), // firstTarget + incrementCalldata // firstCalldata + ); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(batchEnforcer)); + } +}