diff --git a/src/enforcers/CallIntervalEnforcer.sol b/src/enforcers/CallIntervalEnforcer.sol new file mode 100644 index 00000000..8c97fb6a --- /dev/null +++ b/src/enforcers/CallIntervalEnforcer.sol @@ -0,0 +1,70 @@ +// 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 { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +/** + * @title CallIntervalEnforcer + * @notice Enforces a minimum interval between consecutive calls for a given delegation. + * @dev This enforcer restricts the frequency at which a delegation can be used, by requiring a minimum time interval between calls. + * The interval is specified in the `_terms` parameter as a `uint256` encoded in 32 bytes. + * Only operates in the default execution mode. + * + * - The `beforeHook` checks that the required interval has passed since the last call for the given delegation. + * - The interval is enforced per (delegationManager, delegationHash) pair. + * - The `lastCallExecution` mapping tracks the last execution timestamp for each delegation. + * + * Example usage: + * - To allow a delegation to be used only once every 24 hours, set `_terms` to `uint256(86400)` encoded as 32 bytes. + */ +contract CallIntervalEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + /// @notice Tracks the last execution timestamp for each (delegationManager, delegationHash) pair. + /// @dev delegationManager => delegationHash => last execution timestamp + mapping(address delegationManager => mapping(bytes32 delegationHash => uint256 lastCallExecution)) public lastCallExecution; + + /** + * @notice Checks that the minimum interval between calls has passed before allowing execution. + * @dev Reverts if the interval has not elapsed since the last call for this delegation. + * The msg.sender is expected to be the delegation manager address. + * @param _terms Encoded as 32 bytes, representing the minimum interval in seconds between calls. + * @param _mode The execution mode. Must be the default execution mode. + * @param _delegationHash The hash of the delegation being enforced. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata, + bytes32 _delegationHash, + address, + address + ) + public + override + onlyDefaultExecutionMode(_mode) + { + (uint256 callInterval_) = getTermsInfo(_terms); + + uint256 lastCallExecutedAt_ = lastCallExecution[msg.sender][_delegationHash]; + + if (callInterval_ > 0) { + require((block.timestamp - lastCallExecutedAt_) > callInterval_, "CallIntervalEnforcer:early-delegation"); + } + lastCallExecution[msg.sender][_delegationHash] = block.timestamp; + } + + /** + * @notice Decodes the interval from the `_terms` parameter. + * @dev Expects `_terms` to be exactly 32 bytes, representing a uint256 interval in seconds. + * @param _terms The encoded interval. + * @return interval_ The minimum interval in seconds between calls. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (uint256 interval_) { + require(_terms.length == 32, "CallIntervalEnforcer:invalid-terms-length"); + interval_ = uint256(bytes32(_terms)); + } +} diff --git a/test/enforcers/CallIntervalEnforcer.t.sol b/test/enforcers/CallIntervalEnforcer.t.sol new file mode 100644 index 00000000..7509cbf2 --- /dev/null +++ b/test/enforcers/CallIntervalEnforcer.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import { CallIntervalEnforcer } from "../../src/enforcers/CallIntervalEnforcer.sol"; +import { ModeCode } from "../../src/utils/Types.sol"; + +contract CallIntervalEnforcerTest is Test { + CallIntervalEnforcer public enforcer; + address public delegationManager = address(0x1234); + bytes32 public delegationHash = keccak256("test-delegation"); + uint256 public interval = 1 hours; + bytes public terms; + + ModeCode public defaultMode = ModeCode.wrap(bytes32(0)); + + function setUp() public { + vm.warp(10000); + enforcer = new CallIntervalEnforcer(); + terms = abi.encodePacked(interval); + } + + function testRevertOnInvalidTermsLength() public { + bytes memory invalidTerms = new bytes(31); + vm.expectRevert("CallIntervalEnforcer:invalid-terms-length"); + enforcer.getTermsInfo(invalidTerms); + } + + function testFirstCallSucceeds() public { + vm.prank(delegationManager); + enforcer.beforeHook(terms, "", defaultMode, "", delegationHash, address(0), address(0)); + uint256 lastCall = enforcer.lastCallExecution(delegationManager, delegationHash); + assertEq(lastCall, block.timestamp); + } + + function testRevertIfIntervalNotElapsed() public { + // First call + vm.prank(delegationManager); + enforcer.beforeHook(terms, "", defaultMode, "", delegationHash, address(0), address(0)); + // Second call immediately + vm.prank(delegationManager); + vm.expectRevert("CallIntervalEnforcer:early-delegation"); + enforcer.beforeHook(terms, "", defaultMode, "", delegationHash, address(0), address(0)); + } + + function testCallSucceedsAfterInterval() public { + // First call + vm.prank(delegationManager); + enforcer.beforeHook(terms, "", defaultMode, "", delegationHash, address(0), address(0)); + // Warp time forward by interval + 1 + vm.warp(block.timestamp + interval + 1); + // Second call + vm.prank(delegationManager); + enforcer.beforeHook(terms, "", defaultMode, "", delegationHash, address(0), address(0)); + uint256 lastCall = enforcer.lastCallExecution(delegationManager, delegationHash); + assertEq(lastCall, block.timestamp); + } + + function testZeroIntervalAllowsImmediateCalls() public { + bytes memory zeroTerms = abi.encodePacked(uint256(0)); + // First call + vm.prank(delegationManager); + enforcer.beforeHook(zeroTerms, "", defaultMode, "", delegationHash, address(0), address(0)); + // Second call immediately + vm.prank(delegationManager); + enforcer.beforeHook(zeroTerms, "", defaultMode, "", delegationHash, address(0), address(0)); + // Should not revert + } + + function testDifferentDelegationHashesTrackedIndependently() public { + bytes32 hash1 = keccak256("hash1"); + bytes32 hash2 = keccak256("hash2"); + vm.prank(delegationManager); + enforcer.beforeHook(terms, "", defaultMode, "", hash1, address(0), address(0)); + vm.prank(delegationManager); + enforcer.beforeHook(terms, "", defaultMode, "", hash2, address(0), address(0)); + assertEq(enforcer.lastCallExecution(delegationManager, hash1), block.timestamp); + assertEq(enforcer.lastCallExecution(delegationManager, hash2), block.timestamp); + } + + function testDifferentManagersTrackedIndependently() public { + address manager1 = address(0x1111); + address manager2 = address(0x2222); + vm.prank(manager1); + enforcer.beforeHook(terms, "", defaultMode, "", delegationHash, address(0), address(0)); + vm.prank(manager2); + enforcer.beforeHook(terms, "", defaultMode, "", delegationHash, address(0), address(0)); + assertEq(enforcer.lastCallExecution(manager1, delegationHash), block.timestamp); + assertEq(enforcer.lastCallExecution(manager2, delegationHash), block.timestamp); + } +}