Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/enforcers/CallIntervalEnforcer.sol
Original file line number Diff line number Diff line change
@@ -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));
}
}
91 changes: 91 additions & 0 deletions test/enforcers/CallIntervalEnforcer.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}