diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index 7bf3cf7c..5bf93431 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -17,6 +17,7 @@ import { ERC20TransferAmountEnforcer } from "../src/enforcers/ERC20TransferAmoun import { ERC721BalanceGteEnforcer } from "../src/enforcers/ERC721BalanceGteEnforcer.sol"; import { ERC721TransferEnforcer } from "../src/enforcers/ERC721TransferEnforcer.sol"; import { ERC1155BalanceGteEnforcer } from "../src/enforcers/ERC1155BalanceGteEnforcer.sol"; +import { ExactCalldataEnforcer } from "../src/enforcers/ExactCalldataEnforcer.sol"; import { IdEnforcer } from "../src/enforcers/IdEnforcer.sol"; import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; import { NativeBalanceGteEnforcer } from "../src/enforcers/NativeBalanceGteEnforcer.sol"; @@ -26,6 +27,7 @@ import { NativeTokenStreamingEnforcer } from "../src/enforcers/NativeTokenStream import { NonceEnforcer } from "../src/enforcers/NonceEnforcer.sol"; import { OwnershipTransferEnforcer } from "../src/enforcers/OwnershipTransferEnforcer.sol"; import { RedeemerEnforcer } from "../src/enforcers/RedeemerEnforcer.sol"; +import { SpecificActionERC20TransferBatchEnforcer } from "../src/enforcers/SpecificActionERC20TransferBatchEnforcer.sol"; import { ERC20StreamingEnforcer } from "../src/enforcers/ERC20StreamingEnforcer.sol"; import { TimestampEnforcer } from "../src/enforcers/TimestampEnforcer.sol"; import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; @@ -81,6 +83,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new ERC20TransferAmountEnforcer{ salt: salt }()); console2.log("ERC20TransferAmountEnforcer: %s", deployedAddress); + deployedAddress = address(new ERC20StreamingEnforcer{ salt: salt }()); + console2.log("ERC20StreamingEnforcer: %s", deployedAddress); + deployedAddress = address(new ERC721BalanceGteEnforcer{ salt: salt }()); console2.log("ERC721BalanceGteEnforcer: %s", deployedAddress); @@ -90,6 +95,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new ERC1155BalanceGteEnforcer{ salt: salt }()); console2.log("ERC1155BalanceGteEnforcer: %s", deployedAddress); + deployedAddress = address(new ExactCalldataEnforcer{ salt: salt }()); + console2.log("ExactCalldataEnforcer: %s", deployedAddress); + deployedAddress = address(new IdEnforcer{ salt: salt }()); console2.log("IdEnforcer: %s", deployedAddress); @@ -121,8 +129,8 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new RedeemerEnforcer{ salt: salt }()); console2.log("RedeemerEnforcer: %s", deployedAddress); - deployedAddress = address(new ERC20StreamingEnforcer{ salt: salt }()); - console2.log("ERC20StreamingEnforcer: %s", deployedAddress); + deployedAddress = address(new SpecificActionERC20TransferBatchEnforcer{ salt: salt }()); + console2.log("SpecificActionERC20TransferBatchEnforcer: %s", deployedAddress); deployedAddress = address(new TimestampEnforcer{ salt: salt }()); console2.log("TimestampEnforcer: %s", deployedAddress); diff --git a/src/enforcers/ExactCalldataEnforcer.sol b/src/enforcers/ExactCalldataEnforcer.sol new file mode 100644 index 00000000..e8fd2147 --- /dev/null +++ b/src/enforcers/ExactCalldataEnforcer.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode } from "../utils/Types.sol"; + +/** + * @title ExactCalldataEnforcer + * @notice Ensures that the provided execution calldata matches exactly the expected calldata. + * @dev This caveat enforcer operates only in single execution mode. + */ +contract ExactCalldataEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Validates that the execution calldata matches the expected calldata. + * @param _terms The encoded expected calldata. + * @param _mode The execution mode, which must be single. + * @param _executionCallData The calldata provided for execution. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32, + address, + address + ) + public + pure + override + onlySingleExecutionMode(_mode) + { + (,, bytes calldata callData_) = _executionCallData.decodeSingle(); + + bytes memory termsCallData_ = getTermsInfo(_terms); + + require(keccak256(termsCallData_) == keccak256(callData_), "ExactCalldataEnforcer:invalid-calldata"); + } + + /** + * @notice Extracts the expected calldata from the provided terms. + * @param _terms The encoded expected calldata. + * @return callData_ The expected calldata for comparison. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (bytes memory callData_) { + callData_ = _terms; + } +} diff --git a/test/enforcers/ExactCalldataEnforcer.t.sol b/test/enforcers/ExactCalldataEnforcer.t.sol new file mode 100644 index 00000000..18020646 --- /dev/null +++ b/test/enforcers/ExactCalldataEnforcer.t.sol @@ -0,0 +1,296 @@ +// 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 { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { ExactCalldataEnforcer } from "../../src/enforcers/ExactCalldataEnforcer.sol"; +import { BasicERC20, IERC20 } from "../utils/BasicERC20.t.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; + +contract ExactCalldataEnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + ////////////////////////////// State ////////////////////////////// + ExactCalldataEnforcer public exactCalldataEnforcer; + BasicERC20 public basicCF20; + ModeCode public mode = ModeLib.encodeSimpleSingle(); + + ////////////////////////////// Setup ////////////////////////////// + function setUp() public override { + super.setUp(); + exactCalldataEnforcer = new ExactCalldataEnforcer(); + vm.label(address(exactCalldataEnforcer), "Exact Calldata Enforcer"); + basicCF20 = new BasicERC20(address(users.alice.deleGator), "TestToken1", "TestToken1", 100 ether); + } + + ////////////////////////////// Unit Tests ////////////////////////////// + + /// @notice Test that the enforcer passes when the expected calldata exactly matches the executed calldata. + function test_exactCalldataMatches() public { + // Create an execution (for example, a mint on the ERC20 token) + Execution memory execution_ = Execution({ + target: address(basicCF20), + value: 0, + callData: abi.encodeWithSelector(BasicERC20.mint.selector, address(users.alice.deleGator), uint256(100)) + }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + // Use the exact callData as the expected terms + bytes memory terms_ = execution_.callData; + + vm.prank(address(delegationManager)); + exactCalldataEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, keccak256(""), address(0), address(0)); + } + + /// @notice Test that the enforcer reverts when the executed calldata does not exactly match the expected calldata. + function test_exactCalldataFailsWhenMismatch() public { + Execution memory execution_ = Execution({ + target: address(basicCF20), + value: 0, + callData: abi.encodeWithSelector(BasicERC20.mint.selector, address(users.alice.deleGator), uint256(100)) + }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + // Terms to simulate a mismatch + bytes memory terms_ = abi.encodeWithSelector(IERC20.transfer.selector, address(0), uint256(100)); + + vm.prank(address(delegationManager)); + vm.expectRevert("ExactCalldataEnforcer:invalid-calldata"); + exactCalldataEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, keccak256(""), address(0), address(0)); + } + + /// @notice Test that the enforcer works correctly with a dynamic array parameter. + function test_equalDynamicArrayParam() public { + uint256[] memory param = new uint256[](2); + param[0] = 1; + param[1] = 2; + Execution memory execution_ = Execution({ + target: address(0), // Dummy target for testing + value: 0, + callData: abi.encodeWithSelector(DummyContract.arrayFn.selector, param) + }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + bytes memory terms_ = execution_.callData; + + vm.prank(address(delegationManager)); + exactCalldataEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, keccak256(""), address(0), address(0)); + } + + /// @notice Test that the enforcer works correctly with a dynamic string parameter. + function test_equalDynamicStringParam() public { + string memory param_ = "Test string"; + Execution memory execution_ = + Execution({ target: address(0), value: 0, callData: abi.encodeWithSelector(DummyContract.stringFn.selector, param_) }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + bytes memory terms_ = execution_.callData; + + vm.prank(address(delegationManager)); + exactCalldataEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, keccak256(""), address(0), address(0)); + } + + /// @notice Test that the enforcer passes when both expected and execution calldata are empty (ETH transfer). + function test_emptyCalldataMatches() public { + // Create an ETH transfer execution with empty calldata. + Execution memory execution_ = Execution({ target: address(0x1234), value: 1 ether, callData: "" }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + // Expected terms: empty calldata. + bytes memory terms_ = ""; + + vm.prank(address(delegationManager)); + exactCalldataEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, keccak256(""), address(0), address(0)); + } + + /// @notice Test that the enforcer reverts when expected calldata is empty but execution calldata is non-empty. + function test_emptyCalldataFailsWhenMismatch() public { + // Create an ETH transfer execution with non-empty calldata. + Execution memory execution_ = Execution({ target: address(0x1234), value: 1 ether, callData: hex"abcd" }); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + // Expected terms: empty calldata. + bytes memory terms_ = ""; + + vm.prank(address(delegationManager)); + vm.expectRevert("ExactCalldataEnforcer:invalid-calldata"); + exactCalldataEnforcer.beforeHook(terms_, hex"", mode, executionCallData_, keccak256(""), address(0), address(0)); + } + + /// @notice Test that the enforcer reverts when batch-encoded execution calldata is provided. + function test_batchEncodedExecutionReverts() public { + // Batch encode the two executions. + Execution[] memory executions_ = new Execution[](2); + bytes memory batchEncodedCallData_ = ExecutionLib.encodeBatch(executions_); + + // Irrelevant because the batch decoding will fail) + bytes memory terms_ = hex""; + + vm.prank(address(delegationManager)); + // Expect a revert because the enforcer calls decodeSingle() on batch encoded calldata. + vm.expectRevert(); + exactCalldataEnforcer.beforeHook(terms_, hex"", mode, batchEncodedCallData_, keccak256(""), address(0), address(0)); + } + + ////////////////////////////// Integration Tests ////////////////////////////// + + /// @notice Integration test: the enforcer allows a token transfer delegation when calldata matches exactly. + function test_integration_AllowsTokenTransferWhenCalldataMatches() public { + // Ensure Bob starts with a zero balance. + assertEq(basicCF20.balanceOf(address(users.bob.deleGator)), uint256(0)); + + // Create an execution for a token transfer of 1 unit. + Execution memory execution_ = Execution({ + target: address(basicCF20), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether)) + }); + + // Use the actual callData as the expected terms. + bytes memory terms_ = execution_.callData; + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(exactCalldataEnforcer), terms: terms_ }); + 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_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + // Execute Bob's UserOp twice to demonstrate reusability. + invokeDelegation_UserOp(users.bob, delegations_, execution_); + assertEq(basicCF20.balanceOf(address(users.bob.deleGator)), uint256(1 ether)); + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + assertEq(basicCF20.balanceOf(address(users.bob.deleGator)), uint256(2 ether)); + } + + /// @notice Integration test: the enforcer blocks delegation execution when calldata does not match. + function test_integration_BlocksTokenTransferWhenCalldataDiffers() public { + assertEq(basicCF20.balanceOf(address(users.bob.deleGator)), uint256(0)); + + // Create an execution for a token transfer of 2 units. + Execution memory execution_ = Execution({ + target: address(basicCF20), + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(2 ether)) + }); + + // Use expected terms that differ (e.g. a valid callData for a transfer of 1 unit). + bytes memory validCallData_ = + abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether)); + bytes memory terms_ = validCallData_; + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(exactCalldataEnforcer), terms: terms_ }); + 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_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + // Verify that Bob's balance remains unchanged. + assertEq(basicCF20.balanceOf(address(users.bob.deleGator)), uint256(0)); + } + + /// @notice Integration test: ExactCalldataEnforcer allows ETH transfer when both expected and execution calldata are empty. + function test_integration_AllowsETHTransferWhenEmptyCalldataMatches() public { + // Record Carol's initial ETH balance. + uint256 initialBalance = address(users.carol.deleGator).balance; + + // Create an execution for an ETH transfer with empty calldata and a non-zero value. + Execution memory execution_ = Execution({ + target: address(users.carol.deleGator), + value: 10 ether, + callData: "" // Empty calldata for ETH transfer + }); + // Expected terms: empty calldata. + bytes memory terms_ = ""; + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(exactCalldataEnforcer), terms: terms_ }); + 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_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + // Execute the delegation; Bob submits the UserOp. + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + // Verify that Carol's ETH balance increased by 10 ether. + assertEq(address(users.carol.deleGator).balance, initialBalance + 10 ether); + } + + /// @notice Integration test: ExactCalldataEnforcer blocks ETH transfer when expected calldata is empty but execution calldata + /// is non-empty. + function test_integration_BlocksETHTransferWhenEmptyCalldataDiffers() public { + uint256 initialBalance_ = address(users.carol.deleGator).balance; + + // Create an execution for an ETH transfer with non-empty calldata. + Execution memory execution_ = Execution({ + target: address(users.carol.deleGator), + value: 1 ether, + callData: hex"abcd" // Non-empty calldata + }); + // Expected terms: empty calldata. + bytes memory terms_ = ""; + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(exactCalldataEnforcer), terms: terms_ }); + 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_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + // Expect the execution to revert due to calldata mismatch. + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + // Verify that Carol's ETH balance remains unchanged. + assertEq(address(users.carol.deleGator).balance, initialBalance_); + } + + ////////////////////////////// Internal Overrides ////////////////////////////// + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(exactCalldataEnforcer)); + } +} + +/// @dev A dummy contract used for testing dynamic calldata parameters. +contract DummyContract { + function arrayFn(uint256[] calldata _str) public { } + function stringFn(string calldata _str) public { } +}