From acd2894b0d306ca1751a3698fc347d89c0f51bf1 Mon Sep 17 00:00:00 2001 From: Gita Alekhya Paul <54375111+gitaalekhyapaul@users.noreply.github.com> Date: Thu, 14 Nov 2024 03:53:00 +0530 Subject: [PATCH 1/3] feat: ERC20RoyaltyEnforcer Signed-off-by: Gita Alekhya Paul <54375111+gitaalekhyapaul@users.noreply.github.com> --- src/enforcers/ERC20RoyaltyEnforcer.sol | 187 ++++++++ test/enforcers/ERC20RoyaltyEnforcerTest.t.sol | 422 ++++++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 src/enforcers/ERC20RoyaltyEnforcer.sol create mode 100644 test/enforcers/ERC20RoyaltyEnforcerTest.t.sol diff --git a/src/enforcers/ERC20RoyaltyEnforcer.sol b/src/enforcers/ERC20RoyaltyEnforcer.sol new file mode 100644 index 0000000..1b3bc32 --- /dev/null +++ b/src/enforcers/ERC20RoyaltyEnforcer.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode } from "../utils/Types.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title ERC20RoyaltyEnforcer + * @notice Enforces royalty payments when redeeming ERC20 token delegations + * @dev When a delegation is redeemed: + * 1. Validates the execution is a token transfer to this enforcer + * 2. Distributes royalties to recipients specified in terms + * 3. Sends remaining tokens to the redeemer + */ +contract ERC20RoyaltyEnforcer is CaveatEnforcer { + ////////////////////////////// State ////////////////////////////// + + /// @notice Maps hash key to lock status + mapping(bytes32 => bool) public isLocked; + + /// @notice Maps hash key to delegator's balance before execution + mapping(bytes32 => uint256) public delegatorBalanceCache; + + /// @notice Maps hash key to enforcer's balance before execution + mapping(bytes32 => uint256) public enforcerBalanceCache; + + ////////////////////////////// Types ////////////////////////////// + + /// @notice Struct for royalty information + struct RoyaltyInfo { + address recipient; + uint256 amount; + } + + /// @notice Struct to hold execution details + struct ExecutionDetails { + address token; + address recipient; + uint256 amount; + } + + ////////////////////////////// Hooks ////////////////////////////// + + /// @notice Validates and processes the beforeHook logic + /// @dev Validates transfer details and locks execution + /// @param _terms Encoded royalty terms (recipient, amount pairs) + /// @param _mode Execution mode (must be single) + /// @param _executionCallData Encoded execution details + /// @param _delegationHash Hash of the delegation + /// @param _delegator Address of the delegator + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address _delegator, + address + ) + public + override + onlySingleExecutionMode(_mode) + { + // Get execution details + ExecutionDetails memory details = _parseExecution(_executionCallData); + + // Validate transfer + require(details.recipient == address(this), "ERC20RoyaltyEnforcer:invalid-recipient"); + + // Calculate total royalties + uint256 totalRoyalties = _sumRoyalties(_terms); + require(details.amount >= totalRoyalties, "ERC20RoyaltyEnforcer:insufficient-amount"); + + // Cache balances + bytes32 hashKey = keccak256(abi.encode(_delegator, details.token, _delegationHash)); + delegatorBalanceCache[hashKey] = IERC20(details.token).balanceOf(_delegator); + enforcerBalanceCache[hashKey] = IERC20(details.token).balanceOf(address(this)); + + // Lock execution + require(!isLocked[hashKey], "ERC20RoyaltyEnforcer:enforcer-is-locked"); + isLocked[hashKey] = true; + } + + /// @notice Processes royalty distribution + /// @dev Distributes royalties and sends remaining tokens to redeemer + function afterHook( + bytes calldata _terms, + bytes calldata _args, + ModeCode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address _delegator, + address + ) + public + override + { + ExecutionDetails memory details = _parseExecution(_executionCallData); + address redeemer = abi.decode(_args, (address)); + require(redeemer != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer"); + + // Process royalties + _distributeRoyalties(details.token, _terms); + + // Send remaining balance + uint256 remaining = IERC20(details.token).balanceOf(address(this)); + if (remaining > 0) { + require(IERC20(details.token).transfer(redeemer, remaining), "ERC20RoyaltyEnforcer:invalid-transfer"); + } + + // Unlock + bytes32 hashKey = keccak256(abi.encode(_delegator, details.token, _delegationHash)); + isLocked[hashKey] = false; + } + + ////////////////////////////// Public Methods ////////////////////////////// + + /// @notice Returns decoded terms info + /// @param _terms Encoded royalty terms + /// @param _args Encoded redeemer address + /// @return royalties_ Array of royalty info structs + /// @return redeemer_ Address of the redeemer + function getTermsInfo( + bytes calldata _terms, + bytes calldata _args + ) + public + pure + returns (RoyaltyInfo[] memory royalties_, address redeemer_) + { + require(_terms.length % 64 == 0, "ERC20RoyaltyEnforcer:invalid-terms-length"); + uint256 count = _terms.length / 64; + royalties_ = new RoyaltyInfo[](count); + + for (uint256 i; i < count; ++i) { + (address recipient, uint256 amount) = abi.decode(_terms[(i * 64):((i + 1) * 64)], (address, uint256)); + royalties_[i] = RoyaltyInfo({ recipient: recipient, amount: amount }); + } + + redeemer_ = abi.decode(_args, (address)); + require(redeemer_ != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer"); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /// @notice Parses execution calldata into structured data + /// @param _calldata Raw execution calldata + /// @return Structured execution details + function _parseExecution(bytes calldata _calldata) internal pure returns (ExecutionDetails memory) { + (address token, uint256 value, bytes calldata data) = ExecutionLib.decodeSingle(_calldata); + require(value == 0, "ERC20RoyaltyEnforcer:non-zero-value"); + require(data.length >= 4, "ERC20RoyaltyEnforcer:invalid-calldata-length"); + require(bytes4(data[0:4]) == IERC20.transfer.selector, "ERC20RoyaltyEnforcer:invalid-selector"); + + (address recipient, uint256 amount) = abi.decode(data[4:], (address, uint256)); + return ExecutionDetails({ token: token, recipient: recipient, amount: amount }); + } + + /// @notice Calculates total royalties from terms + /// @param _terms Encoded royalty terms + /// @return Total royalty amount + function _sumRoyalties(bytes calldata _terms) internal pure returns (uint256) { + require(_terms.length % 64 == 0, "ERC20RoyaltyEnforcer:invalid-terms-length"); + uint256 total; + uint256 chunks = _terms.length / 64; + + for (uint256 i; i < chunks; ++i) { + (, uint256 amount) = abi.decode(_terms[(i * 64):((i + 1) * 64)], (address, uint256)); + total += amount; + } + return total; + } + + /// @notice Distributes royalties to recipients + /// @param _token Token address + /// @param _terms Encoded royalty terms + function _distributeRoyalties(address _token, bytes calldata _terms) internal { + uint256 chunks = _terms.length / 64; + for (uint256 i; i < chunks; ++i) { + (address recipient, uint256 amount) = abi.decode(_terms[(i * 64):((i + 1) * 64)], (address, uint256)); + require(IERC20(_token).transfer(recipient, amount), "ERC20RoyaltyEnforcer:invalid-transfer"); + } + } +} diff --git a/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol b/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol new file mode 100644 index 0000000..02a3a14 --- /dev/null +++ b/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol @@ -0,0 +1,422 @@ +// 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 { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; + +import { Execution, Caveat, Delegation, ModeCode } from "../../src/utils/Types.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { ERC20RoyaltyEnforcer } from "../../src/enforcers/ERC20RoyaltyEnforcer.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; + +/** + * @title ERC20RoyaltyEnforcerTest + * @notice Test suite for ERC20RoyaltyEnforcer contract + * @dev Tests royalty distribution functionality for ERC20 token delegations + */ +contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { + ////////////////////////////// State ////////////////////////////// + ERC20RoyaltyEnforcer public enforcer; + BasicERC20 public token; + ModeCode public mode = ModeLib.encodeSimpleSingle(); + + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + + ////////////////////////////// Set up ////////////////////////////// + + /// @notice Deploy contracts and set up test environment + function setUp() public override { + super.setUp(); + enforcer = new ERC20RoyaltyEnforcer(); + token = new BasicERC20(address(this), "TEST", "TEST", 0); + vm.label(address(enforcer), "ERC20 Royalty Enforcer"); + vm.label(address(token), "Test Token"); + } + + //////////////////// Valid cases ////////////////////// + + /// @notice Validates that terms and args are decoded correctly + /// @dev Tests the decoding of royalty recipients, amounts, and redeemer address + function test_decodedTheTerms() public { + address redeemer = address(users.eve.deleGator); + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + (ERC20RoyaltyEnforcer.RoyaltyInfo[] memory royalties, address decodedRedeemer) = enforcer.getTermsInfo(terms, args); + + assertEq(royalties.length, 2); + assertEq(royalties[0].recipient, address(users.carol.deleGator)); + assertEq(royalties[0].amount, 200); + assertEq(royalties[1].recipient, address(users.dave.deleGator)); + assertEq(royalties[1].amount, 100); + assertEq(decodedRedeemer, redeemer); + } + + /// @notice Tests a complete valid royalty execution flow + /// @dev Verifies token transfers and final balances for all parties + function test_validRoyaltyExecution() public { + address redeemer = address(users.eve.deleGator); + uint256 initialAmount = 1000; + + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + + vm.startPrank(address(users.alice.deleGator)); + token.transfer(address(enforcer), initialAmount); + + vm.startPrank(address(delegationManager)); + enforcer.afterHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + + assertEq(token.balanceOf(address(users.carol.deleGator)), 200); + assertEq(token.balanceOf(address(users.dave.deleGator)), 100); + assertEq(token.balanceOf(redeemer), 700); + } + + /// @notice Tests the enforcer's locking mechanism + /// @dev Verifies lock/unlock behavior and revert on locked state + function test_enforcerLocking() public { + address redeemer = address(users.eve.deleGator); + uint256 initialAmount = 1000; + + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + bytes32 delegationHash_ = keccak256("test"); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook( + terms, args, mode, executionCallData, delegationHash_, address(users.alice.deleGator), address(users.bob.deleGator) + ); + + bytes32 hashKey_ = keccak256(abi.encode(address(users.alice.deleGator), address(token), delegationHash_)); + assertTrue(enforcer.isLocked(hashKey_)); + + vm.expectRevert("ERC20RoyaltyEnforcer:enforcer-is-locked"); + enforcer.beforeHook( + terms, args, mode, executionCallData, delegationHash_, address(users.alice.deleGator), address(users.bob.deleGator) + ); + + vm.startPrank(address(users.alice.deleGator)); + token.transfer(address(enforcer), initialAmount); + + vm.startPrank(address(delegationManager)); + enforcer.afterHook( + terms, args, mode, executionCallData, delegationHash_, address(users.alice.deleGator), address(users.bob.deleGator) + ); + + assertFalse(enforcer.isLocked(hashKey_)); + } + + /// @notice Tests balance caching functionality + /// @dev Verifies balances are properly cached in beforeHook + function test_balanceCaching() public { + address redeemer = address(users.eve.deleGator); + uint256 initialAmount = 1000; + + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + bytes32 delegationHash = keccak256("test"); + bytes32 hashKey = keccak256(abi.encode(address(users.alice.deleGator), address(token), delegationHash)); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook( + terms, args, mode, executionCallData, delegationHash, address(users.alice.deleGator), address(users.bob.deleGator) + ); + + // Verify cached balances + assertEq(enforcer.delegatorBalanceCache(hashKey), initialAmount, "Delegator balance not cached correctly"); + assertEq(enforcer.enforcerBalanceCache(hashKey), 0, "Enforcer balance not cached correctly"); + } + + //////////////////// Invalid cases ////////////////////// + + /// @notice Tests reversion on invalid execution mode + /// @dev Should revert when using batch mode instead of single + function test_revertOnInvalidMode() public { + ModeCode invalidMode = ModeLib.encodeSimpleBatch(); + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200)); + bytes memory args = abi.encode(address(users.eve.deleGator)); + + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook( + terms, args, invalidMode, hex"", bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + /// @notice Tests reversion on invalid terms length + /// @dev Terms must be multiple of 64 bytes (address + uint256) + function test_revertOnInvalidTermsLength() public { + bytes memory invalidTerms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator)); + bytes memory args = abi.encode(address(users.eve.deleGator)); + + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-terms-length"); + enforcer.getTermsInfo(invalidTerms, args); + } + + /// @notice Tests reversion on invalid redeemer address + /// @dev Should revert when redeemer is zero address + function test_revertOnInvalidRedeemer() public { + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory invalidArgs = abi.encode(address(0)); + + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-redeemer"); + enforcer.getTermsInfo(terms, invalidArgs); + } + + /// @notice Tests reversion on invalid transfer recipient + /// @dev Should revert when transfer recipient is not the enforcer + function test_revertOnInvalidRecipient() public { + uint256 initialAmount = 1000; + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(address(users.eve.deleGator)); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.frank.deleGator), initialAmount) // Wrong + // recipient + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-recipient"); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + /// @notice Tests reversion on insufficient transfer amount + /// @dev Should revert when transfer amount is less than total royalties + function test_revertOnInsufficientAmount() public { + uint256 initialAmount = 200; // Less than total royalties (300) + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(address(users.eve.deleGator)); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + vm.expectRevert("ERC20RoyaltyEnforcer:insufficient-amount"); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + /// @notice Tests reversion on invalid transfer execution + /// @dev Should revert when token transfer fails in afterHook + function test_revertOnInvalidTransfer() public { + address redeemer = address(users.eve.deleGator); + uint256 initialAmount = 300; + + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + + vm.startPrank(address(users.alice.deleGator)); + token.transfer(address(enforcer), initialAmount - 100); // transfer less than total royalties (trying to emulate a failed + // transfer) + + vm.startPrank(address(delegationManager)); + vm.expectRevert(abi.encodeWithSelector(ERC20InsufficientBalance.selector, address(enforcer), 0, 100)); + enforcer.afterHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + /// @notice Tests reversion on invalid selector + /// @dev Should revert when using non-transfer selector + function test_revertOnInvalidSelector() public { + address redeemer = address(users.eve.deleGator); + uint256 initialAmount = 1000; + + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + // Use approve selector instead of transfer + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.approve.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-selector"); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + /// @notice Tests reversion on non-zero value + /// @dev Should revert when execution includes ETH value + function test_revertOnNonZeroValue() public { + address redeemer = address(users.eve.deleGator); + uint256 initialAmount = 1000; + + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + Execution memory execution = Execution({ + target: address(token), + value: 1 ether, // Non-zero value + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + vm.expectRevert("ERC20RoyaltyEnforcer:non-zero-value"); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + /// @notice Tests reversion on invalid calldata length + /// @dev Should revert when calldata is too short + function test_revertOnInvalidCalldataLength() public { + address redeemer = address(users.eve.deleGator); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(redeemer); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: hex"12" // Invalid short calldata + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-calldata-length"); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + // Test failed transfer to royalty recipient + function test_revertOnRoyaltyTransferFail() public { + uint256 initialAmount = 1000; + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(address(users.eve.deleGator)); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + // Make transfer fail + vm.mockCall(address(token), abi.encodeWithSelector(IERC20.transfer.selector), abi.encode(false)); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-transfer"); + enforcer.afterHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + } + + // Test zero remaining balance + function test_noRemainingBalance() public { + uint256 initialAmount = 300; // Exact royalty amount + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = abi.encode(address(users.eve.deleGator)); + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + vm.startPrank(address(users.alice.deleGator)); + token.transfer(address(enforcer), initialAmount); + enforcer.afterHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); + + // Verify no remaining balance transfer occurred + assertEq(token.balanceOf(address(users.eve.deleGator)), 0); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /// @notice Returns the enforcer instance for base test contract + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(enforcer)); + } +} From 10c15f53266082015f7bad3a8f51b47744ab8275 Mon Sep 17 00:00:00 2001 From: Gita Alekhya Paul <54375111+gitaalekhyapaul@users.noreply.github.com> Date: Fri, 15 Nov 2024 19:59:16 +0530 Subject: [PATCH 2/3] fix: fetch redeemer address from params, not args Signed-off-by: Gita Alekhya Paul <54375111+gitaalekhyapaul@users.noreply.github.com> --- script/DeployCaveatEnforcers.s.sol | 6 +- src/enforcers/ERC20RoyaltyEnforcer.sol | 36 +++---- test/enforcers/ERC20RoyaltyEnforcerTest.t.sol | 93 ++++++++++++------- 3 files changed, 82 insertions(+), 53 deletions(-) diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index ab580b2..3f92dd9 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -28,7 +28,7 @@ import { OwnershipTransferEnforcer } from "../src/enforcers/OwnershipTransferEnf import { RedeemerEnforcer } from "../src/enforcers/RedeemerEnforcer.sol"; import { TimestampEnforcer } from "../src/enforcers/TimestampEnforcer.sol"; import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; - +import { ERC20RoyaltyEnforcer } from "../src/enforcers/ERC20RoyaltyEnforcer.sol"; /** * @title DeployCaveatEnforcers * @notice Deploys the suite of caveat enforcers to be used with the Delegation Framework. @@ -36,6 +36,7 @@ import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; * @dev run the script with: * forge script script/DeployCaveatEnforcers.s.sol --rpc-url --private-key $PRIVATE_KEY --broadcast */ + contract DeployCaveatEnforcers is Script { bytes32 salt; IEntryPoint entryPoint; @@ -127,6 +128,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new ValueLteEnforcer{ salt: salt }()); console2.log("ValueLteEnforcer: %s", deployedAddress); + deployedAddress = address(new ERC20RoyaltyEnforcer{ salt: salt }()); + console2.log("ERC20RoyaltyEnforcer: %s", deployedAddress); + vm.stopBroadcast(); } } diff --git a/src/enforcers/ERC20RoyaltyEnforcer.sol b/src/enforcers/ERC20RoyaltyEnforcer.sol index 1b3bc32..5c9cc32 100644 --- a/src/enforcers/ERC20RoyaltyEnforcer.sol +++ b/src/enforcers/ERC20RoyaltyEnforcer.sol @@ -58,12 +58,19 @@ contract ERC20RoyaltyEnforcer is CaveatEnforcer { bytes calldata _executionCallData, bytes32 _delegationHash, address _delegator, - address + address _redeemer ) public override onlySingleExecutionMode(_mode) { + // Validate redeemer is not zero address + require(_redeemer != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer"); + + // Validate the terms info length is more than zero + RoyaltyInfo[] memory royalties_ = getTermsInfo(_terms); + require(royalties_.length > 0, "ERC20RoyaltyEnforcer:invalid-royalties-length"); + // Get execution details ExecutionDetails memory details = _parseExecution(_executionCallData); @@ -88,19 +95,18 @@ contract ERC20RoyaltyEnforcer is CaveatEnforcer { /// @dev Distributes royalties and sends remaining tokens to redeemer function afterHook( bytes calldata _terms, - bytes calldata _args, + bytes calldata, ModeCode, - bytes calldata _executionCallData, + bytes calldata _executionCalldata, bytes32 _delegationHash, address _delegator, - address + address _redeemer ) public override { - ExecutionDetails memory details = _parseExecution(_executionCallData); - address redeemer = abi.decode(_args, (address)); - require(redeemer != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer"); + ExecutionDetails memory details = _parseExecution(_executionCalldata); + require(_redeemer != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer"); // Process royalties _distributeRoyalties(details.token, _terms); @@ -108,7 +114,7 @@ contract ERC20RoyaltyEnforcer is CaveatEnforcer { // Send remaining balance uint256 remaining = IERC20(details.token).balanceOf(address(this)); if (remaining > 0) { - require(IERC20(details.token).transfer(redeemer, remaining), "ERC20RoyaltyEnforcer:invalid-transfer"); + require(IERC20(details.token).transfer(_redeemer, remaining), "ERC20RoyaltyEnforcer:invalid-transfer"); } // Unlock @@ -120,17 +126,8 @@ contract ERC20RoyaltyEnforcer is CaveatEnforcer { /// @notice Returns decoded terms info /// @param _terms Encoded royalty terms - /// @param _args Encoded redeemer address /// @return royalties_ Array of royalty info structs - /// @return redeemer_ Address of the redeemer - function getTermsInfo( - bytes calldata _terms, - bytes calldata _args - ) - public - pure - returns (RoyaltyInfo[] memory royalties_, address redeemer_) - { + function getTermsInfo(bytes calldata _terms) public pure returns (RoyaltyInfo[] memory royalties_) { require(_terms.length % 64 == 0, "ERC20RoyaltyEnforcer:invalid-terms-length"); uint256 count = _terms.length / 64; royalties_ = new RoyaltyInfo[](count); @@ -139,9 +136,6 @@ contract ERC20RoyaltyEnforcer is CaveatEnforcer { (address recipient, uint256 amount) = abi.decode(_terms[(i * 64):((i + 1) * 64)], (address, uint256)); royalties_[i] = RoyaltyInfo({ recipient: recipient, amount: amount }); } - - redeemer_ = abi.decode(_args, (address)); - require(redeemer_ != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer"); } ////////////////////////////// Internal Methods ////////////////////////////// diff --git a/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol b/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol index 02a3a14..e28736e 100644 --- a/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol +++ b/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol @@ -41,30 +41,26 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { /// @notice Validates that terms and args are decoded correctly /// @dev Tests the decoding of royalty recipients, amounts, and redeemer address function test_decodedTheTerms() public { - address redeemer = address(users.eve.deleGator); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); - (ERC20RoyaltyEnforcer.RoyaltyInfo[] memory royalties, address decodedRedeemer) = enforcer.getTermsInfo(terms, args); + (ERC20RoyaltyEnforcer.RoyaltyInfo[] memory royalties) = enforcer.getTermsInfo(terms); assertEq(royalties.length, 2); assertEq(royalties[0].recipient, address(users.carol.deleGator)); assertEq(royalties[0].amount, 200); assertEq(royalties[1].recipient, address(users.dave.deleGator)); assertEq(royalties[1].amount, 100); - assertEq(decodedRedeemer, redeemer); } /// @notice Tests a complete valid royalty execution flow /// @dev Verifies token transfers and final balances for all parties function test_validRoyaltyExecution() public { - address redeemer = address(users.eve.deleGator); uint256 initialAmount = 1000; token.mint(address(users.alice.deleGator), initialAmount); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); + bytes memory args = hex""; Execution memory execution = Execution({ target: address(token), @@ -88,19 +84,18 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { assertEq(token.balanceOf(address(users.carol.deleGator)), 200); assertEq(token.balanceOf(address(users.dave.deleGator)), 100); - assertEq(token.balanceOf(redeemer), 700); + assertEq(token.balanceOf(address(users.bob.deleGator)), 700); } /// @notice Tests the enforcer's locking mechanism /// @dev Verifies lock/unlock behavior and revert on locked state function test_enforcerLocking() public { - address redeemer = address(users.eve.deleGator); uint256 initialAmount = 1000; token.mint(address(users.alice.deleGator), initialAmount); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); + bytes memory args = hex""; Execution memory execution = Execution({ target: address(token), @@ -138,13 +133,12 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { /// @notice Tests balance caching functionality /// @dev Verifies balances are properly cached in beforeHook function test_balanceCaching() public { - address redeemer = address(users.eve.deleGator); uint256 initialAmount = 1000; token.mint(address(users.alice.deleGator), initialAmount); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); + bytes memory args = hex""; Execution memory execution = Execution({ target: address(token), @@ -185,20 +179,33 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { /// @dev Terms must be multiple of 64 bytes (address + uint256) function test_revertOnInvalidTermsLength() public { bytes memory invalidTerms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator)); - bytes memory args = abi.encode(address(users.eve.deleGator)); vm.expectRevert("ERC20RoyaltyEnforcer:invalid-terms-length"); - enforcer.getTermsInfo(invalidTerms, args); + enforcer.getTermsInfo(invalidTerms); } - /// @notice Tests reversion on invalid redeemer address - /// @dev Should revert when redeemer is zero address - function test_revertOnInvalidRedeemer() public { - bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory invalidArgs = abi.encode(address(0)); + /// @notice Tests reversion on empty royalties array + /// @dev Should revert when no royalty recipients are specified + function test_revertOnEmptyRoyalties() public { + uint256 initialAmount = 1000; + token.mint(address(users.alice.deleGator), initialAmount); - vm.expectRevert("ERC20RoyaltyEnforcer:invalid-redeemer"); - enforcer.getTermsInfo(terms, invalidArgs); + // Create empty terms (no royalty recipients) + bytes memory terms = bytes(""); + bytes memory args = hex""; + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-royalties-length"); + enforcer.beforeHook( + terms, args, mode, executionCallData, bytes32(0), address(users.alice.deleGator), address(users.bob.deleGator) + ); } /// @notice Tests reversion on invalid transfer recipient @@ -225,6 +232,35 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { ); } + /// @notice Tests reversion on invalid redeemer address + /// @dev Should revert when redeemer is zero address + function test_revertOnInvalidRedeemer() public { + uint256 initialAmount = 1000; + token.mint(address(users.alice.deleGator), initialAmount); + + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200)); + bytes memory args = hex""; + + Execution memory execution = Execution({ + target: address(token), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(enforcer), initialAmount) + }); + bytes memory executionCallData = ExecutionLib.encodeSingle(execution.target, execution.value, execution.callData); + + vm.startPrank(address(delegationManager)); + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-redeemer"); + enforcer.beforeHook( + terms, + args, + mode, + executionCallData, + bytes32(0), + address(users.alice.deleGator), + address(0) // Zero address redeemer + ); + } + /// @notice Tests reversion on insufficient transfer amount /// @dev Should revert when transfer amount is less than total royalties function test_revertOnInsufficientAmount() public { @@ -232,7 +268,7 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { token.mint(address(users.alice.deleGator), initialAmount); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(address(users.eve.deleGator)); + bytes memory args = hex""; Execution memory execution = Execution({ target: address(token), @@ -251,13 +287,12 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { /// @notice Tests reversion on invalid transfer execution /// @dev Should revert when token transfer fails in afterHook function test_revertOnInvalidTransfer() public { - address redeemer = address(users.eve.deleGator); uint256 initialAmount = 300; token.mint(address(users.alice.deleGator), initialAmount); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); + bytes memory args = hex""; Execution memory execution = Execution({ target: address(token), @@ -272,7 +307,7 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { ); vm.startPrank(address(users.alice.deleGator)); - token.transfer(address(enforcer), initialAmount - 100); // transfer less than total royalties (trying to emulate a failed + token.transfer(address(enforcer), initialAmount - 100); // transfer less than total royalties (trying to invoke a failed // transfer) vm.startPrank(address(delegationManager)); @@ -285,13 +320,12 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { /// @notice Tests reversion on invalid selector /// @dev Should revert when using non-transfer selector function test_revertOnInvalidSelector() public { - address redeemer = address(users.eve.deleGator); uint256 initialAmount = 1000; token.mint(address(users.alice.deleGator), initialAmount); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); + bytes memory args = hex""; // Use approve selector instead of transfer Execution memory execution = Execution({ @@ -311,13 +345,12 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { /// @notice Tests reversion on non-zero value /// @dev Should revert when execution includes ETH value function test_revertOnNonZeroValue() public { - address redeemer = address(users.eve.deleGator); uint256 initialAmount = 1000; token.mint(address(users.alice.deleGator), initialAmount); bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); + bytes memory args = hex""; Execution memory execution = Execution({ target: address(token), @@ -336,10 +369,8 @@ contract ERC20RoyaltyEnforcerTest is CaveatEnforcerBaseTest { /// @notice Tests reversion on invalid calldata length /// @dev Should revert when calldata is too short function test_revertOnInvalidCalldataLength() public { - address redeemer = address(users.eve.deleGator); - bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); - bytes memory args = abi.encode(redeemer); + bytes memory args = hex""; Execution memory execution = Execution({ target: address(token), From 09a18592c6383dc86f2ab78178feb8200a8dbe8e Mon Sep 17 00:00:00 2001 From: Gita Alekhya Paul <54375111+gitaalekhyapaul@users.noreply.github.com> Date: Thu, 20 Feb 2025 23:48:31 +0530 Subject: [PATCH 3/3] feat: limited calls by redeemer enforcer Signed-off-by: Gita Alekhya Paul <54375111+gitaalekhyapaul@users.noreply.github.com> --- script/DeployCaveatEnforcers.s.sol | 4 + .../LimitedCallsByRedeemerEnforcer.sol | 58 ++++++ .../LimitedCallsByRedeemerEnforcer.t.sol | 190 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/enforcers/LimitedCallsByRedeemerEnforcer.sol create mode 100644 test/enforcers/LimitedCallsByRedeemerEnforcer.t.sol diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index 3f92dd9..5a21824 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -29,6 +29,7 @@ import { RedeemerEnforcer } from "../src/enforcers/RedeemerEnforcer.sol"; import { TimestampEnforcer } from "../src/enforcers/TimestampEnforcer.sol"; import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; import { ERC20RoyaltyEnforcer } from "../src/enforcers/ERC20RoyaltyEnforcer.sol"; +import { LimitedCallsByRedeemerEnforcer } from "../src/enforcers/LimitedCallsByRedeemerEnforcer.sol"; /** * @title DeployCaveatEnforcers * @notice Deploys the suite of caveat enforcers to be used with the Delegation Framework. @@ -131,6 +132,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new ERC20RoyaltyEnforcer{ salt: salt }()); console2.log("ERC20RoyaltyEnforcer: %s", deployedAddress); + deployedAddress = address(new LimitedCallsByRedeemerEnforcer{ salt: salt }()); + console2.log("LimitedCallsByRedeemerEnforcer: %s", deployedAddress); + vm.stopBroadcast(); } } diff --git a/src/enforcers/LimitedCallsByRedeemerEnforcer.sol b/src/enforcers/LimitedCallsByRedeemerEnforcer.sol new file mode 100644 index 0000000..51a63a8 --- /dev/null +++ b/src/enforcers/LimitedCallsByRedeemerEnforcer.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode } from "../utils/Types.sol"; + +/** + * @title Limited Calls by Redeemer Enforcer Contract + * @dev This contract extends the CaveatEnforcer contract. It provides functionality to enforce a limit on the number of times + * a redeemer may perform transactions on behalf of the delegator. + */ +contract LimitedCallsByRedeemerEnforcer is CaveatEnforcer { + ////////////////////////////// State ////////////////////////////// + + mapping(address delegationManager => mapping(bytes32 delegationHash => mapping(address redeemer => uint256 count))) public + callCounts; + + ////////////////////////////// Events ////////////////////////////// + + event IncreasedCount( + address indexed sender, address indexed redeemer, bytes32 indexed delegationHash, uint256 limit, uint256 callCount + ); + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Allows the delegator to specify a maximum number of times the recipient may perform transactions on their behalf. + * @param _terms - The maximum number of times the delegate may perform transactions on their behalf. + * @param _delegationHash - The hash of the delegation being operated on. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode, + bytes calldata, + bytes32 _delegationHash, + address, + address _redeemer + ) + public + override + { + uint256 limit_ = getTermsInfo(_terms); + uint256 callCounts_ = ++callCounts[msg.sender][_delegationHash][_redeemer]; + require(callCounts_ <= limit_, "LimitedCallsByRedeemerEnforcer:limit-exceeded"); + emit IncreasedCount(msg.sender, _redeemer, _delegationHash, limit_, callCounts_); + } + + /** + * @notice Decodes the terms used in this CaveatEnforcer. + * @param _terms encoded data that is used during the execution hooks. + * @return limit_ The maximum number of times the delegate may perform transactions on the delegator's behalf. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (uint256 limit_) { + require(_terms.length == 32, "LimitedCallsByRedeemerEnforcer:invalid-terms-length"); + limit_ = uint256(bytes32(_terms)); + } +} diff --git a/test/enforcers/LimitedCallsByRedeemerEnforcer.t.sol b/test/enforcers/LimitedCallsByRedeemerEnforcer.t.sol new file mode 100644 index 0000000..4ee18cf --- /dev/null +++ b/test/enforcers/LimitedCallsByRedeemerEnforcer.t.sol @@ -0,0 +1,190 @@ +// 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 "../../src/utils/Types.sol"; +import { Execution } from "../../src/utils/Types.sol"; +import { Counter } from "../utils/Counter.t.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { LimitedCallsByRedeemerEnforcer } from "../../src/enforcers/LimitedCallsByRedeemerEnforcer.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { IDelegationManager } from "../../src/interfaces/IDelegationManager.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; + +contract LimitedCallsByRedeemerEnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + ////////////////////////////// Events ////////////////////////////// + event IncreasedCount( + address indexed sender, address indexed redeemer, bytes32 indexed delegationHash, uint256 limit, uint256 callCount + ); + + ////////////////////// State ////////////////////// + + LimitedCallsByRedeemerEnforcer public limitedCallsByRedeemerEnforcer; + ModeCode public mode = ModeLib.encodeSimpleSingle(); + + ////////////////////// Set up ////////////////////// + + function setUp() public override { + super.setUp(); + limitedCallsByRedeemerEnforcer = new LimitedCallsByRedeemerEnforcer(); + vm.label(address(limitedCallsByRedeemerEnforcer), "Limited Calls by Redeemer Enforcer"); + } + + ////////////////////// Valid cases ////////////////////// + + // should SUCCEED to INVOKE method BELOW limit number + function test_methodCanBeCalledBelowLimitNumber() public { + // Create the execution that would be executed + Execution memory execution_ = Execution({ + target: address(aliceDeleGatorCounter), + value: 0, + callData: abi.encodeWithSelector(Counter.increment.selector) + }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + uint256 transactionsLimit_ = 1; + bytes memory inputTerms_ = abi.encodePacked(transactionsLimit_); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(limitedCallsByRedeemerEnforcer), terms: inputTerms_ }); + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + // Get delegation hash + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + assertEq( + limitedCallsByRedeemerEnforcer.callCounts(address(delegationManager), delegationHash_, address(users.bob.deleGator)), 0 + ); + vm.prank(address(delegationManager)); + vm.expectEmit(true, true, true, true, address(limitedCallsByRedeemerEnforcer)); + emit IncreasedCount(address(delegationManager), address(users.bob.deleGator), delegationHash_, 1, 1); + limitedCallsByRedeemerEnforcer.beforeHook( + inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(users.bob.deleGator) + ); + + assertEq( + limitedCallsByRedeemerEnforcer.callCounts(address(delegationManager), delegationHash_, address(users.bob.deleGator)), 1 + ); + } + + ////////////////////// Invalid cases ////////////////////// + + // should FAIL to INVOKE method ABOVE limit number + function test_methodFailsIfCalledAboveLimitNumber() public { + // Create the execution that would be executed + Execution memory execution_ = Execution({ + target: address(aliceDeleGatorCounter), + value: 0, + callData: abi.encodeWithSelector(Counter.increment.selector) + }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + uint256 transactionsLimit_ = 1; + bytes memory inputTerms_ = abi.encodePacked(transactionsLimit_); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(limitedCallsByRedeemerEnforcer), terms: inputTerms_ }); + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + // Get delegation hash + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + assertEq( + limitedCallsByRedeemerEnforcer.callCounts(address(delegationManager), delegationHash_, address(users.bob.deleGator)), 0 + ); + vm.startPrank(address(delegationManager)); + limitedCallsByRedeemerEnforcer.beforeHook( + inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(users.bob.deleGator) + ); + vm.expectRevert("LimitedCallsByRedeemerEnforcer:limit-exceeded"); + limitedCallsByRedeemerEnforcer.beforeHook( + inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(users.bob.deleGator) + ); + assertEq( + limitedCallsByRedeemerEnforcer.callCounts(address(delegationManager), delegationHash_, address(users.bob.deleGator)), + transactionsLimit_ + ); + } + + // should FAIL to INVOKE with invalid input terms + function test_methodFailsIfCalledWithInvalidInputTerms() public { + Execution memory execution_; + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + bytes memory terms_ = abi.encodePacked(uint32(1)); + vm.expectRevert("LimitedCallsByRedeemerEnforcer:invalid-terms-length"); + limitedCallsByRedeemerEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, bytes32(0), address(0), address(0)); + + terms_ = abi.encodePacked(uint256(1), uint256(1)); + vm.expectRevert("LimitedCallsByRedeemerEnforcer:invalid-terms-length"); + limitedCallsByRedeemerEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, bytes32(0), address(0), address(0)); + } + + ////////////////////// Integration ////////////////////// + + // should FAIL to increment counter ABOVE limit number Integration + function test_methodFailsAboveLimitIntegration() public { + uint256 initialValue_ = aliceDeleGatorCounter.count(); + + // Create the execution that would be executed + Execution memory execution_ = Execution({ + target: address(aliceDeleGatorCounter), + value: 0, + callData: abi.encodeWithSelector(Counter.increment.selector) + }); + bytes memory inputTerms_ = abi.encodePacked(uint256(1)); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(limitedCallsByRedeemerEnforcer), terms: inputTerms_ }); + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + + delegation_ = signDelegation(users.alice, delegation_); + + // Get delegation hash + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + assertEq( + limitedCallsByRedeemerEnforcer.callCounts(address(delegationManager), delegationHash_, address(users.bob.deleGator)), 0 + ); + + // Execute Bob's UserOp + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + // Validate that the count has increased by 1 + uint256 valueAfter_ = aliceDeleGatorCounter.count(); + assertEq(valueAfter_, initialValue_ + 1); + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + // Validate that the count has not increased + assertEq(aliceDeleGatorCounter.count(), valueAfter_); + assertEq( + limitedCallsByRedeemerEnforcer.callCounts(address(delegationManager), delegationHash_, address(users.bob.deleGator)), 1 + ); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(limitedCallsByRedeemerEnforcer)); + } +}