diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index ab580b2..5a21824 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -28,7 +28,8 @@ 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"; +import { LimitedCallsByRedeemerEnforcer } from "../src/enforcers/LimitedCallsByRedeemerEnforcer.sol"; /** * @title DeployCaveatEnforcers * @notice Deploys the suite of caveat enforcers to be used with the Delegation Framework. @@ -36,6 +37,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 +129,12 @@ 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); + + deployedAddress = address(new LimitedCallsByRedeemerEnforcer{ salt: salt }()); + console2.log("LimitedCallsByRedeemerEnforcer: %s", deployedAddress); + vm.stopBroadcast(); } } diff --git a/src/enforcers/ERC20RoyaltyEnforcer.sol b/src/enforcers/ERC20RoyaltyEnforcer.sol new file mode 100644 index 0000000..5c9cc32 --- /dev/null +++ b/src/enforcers/ERC20RoyaltyEnforcer.sol @@ -0,0 +1,181 @@ +// 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 _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); + + // 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, + ModeCode, + bytes calldata _executionCalldata, + bytes32 _delegationHash, + address _delegator, + address _redeemer + ) + public + override + { + ExecutionDetails memory details = _parseExecution(_executionCalldata); + 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 + /// @return royalties_ Array of royalty info structs + 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); + + 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 }); + } + } + + ////////////////////////////// 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/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/ERC20RoyaltyEnforcerTest.t.sol b/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol new file mode 100644 index 0000000..e28736e --- /dev/null +++ b/test/enforcers/ERC20RoyaltyEnforcerTest.t.sol @@ -0,0 +1,453 @@ +// 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 { + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + + (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); + } + + /// @notice Tests a complete valid royalty execution flow + /// @dev Verifies token transfers and final balances for all parties + function test_validRoyaltyExecution() 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 = 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)); + 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(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 { + 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 = 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); + + 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 { + 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 = 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); + + 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)); + + vm.expectRevert("ERC20RoyaltyEnforcer:invalid-terms-length"); + enforcer.getTermsInfo(invalidTerms); + } + + /// @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); + + // 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 + /// @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 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 { + 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 = 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: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 { + 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 = 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)); + 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 invoke 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 { + 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 = hex""; + + // 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 { + 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 = hex""; + + 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 { + bytes memory terms = abi.encode(address(users.carol.deleGator), uint256(200), address(users.dave.deleGator), uint256(100)); + bytes memory args = hex""; + + 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)); + } +} 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)); + } +}