diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index 5bf93431..43dac117 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -14,6 +14,8 @@ import { BlockNumberEnforcer } from "../src/enforcers/BlockNumberEnforcer.sol"; import { DeployedEnforcer } from "../src/enforcers/DeployedEnforcer.sol"; import { ERC20BalanceGteEnforcer } from "../src/enforcers/ERC20BalanceGteEnforcer.sol"; import { ERC20TransferAmountEnforcer } from "../src/enforcers/ERC20TransferAmountEnforcer.sol"; +import { ERC20StreamingEnforcer } from "../src/enforcers/ERC20StreamingEnforcer.sol"; +import { ERC20PeriodTransferEnforcer } from "../src/enforcers/ERC20PeriodTransferEnforcer.sol"; import { ERC721BalanceGteEnforcer } from "../src/enforcers/ERC721BalanceGteEnforcer.sol"; import { ERC721TransferEnforcer } from "../src/enforcers/ERC721TransferEnforcer.sol"; import { ERC1155BalanceGteEnforcer } from "../src/enforcers/ERC1155BalanceGteEnforcer.sol"; @@ -22,13 +24,13 @@ import { IdEnforcer } from "../src/enforcers/IdEnforcer.sol"; import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; import { NativeBalanceGteEnforcer } from "../src/enforcers/NativeBalanceGteEnforcer.sol"; import { NativeTokenPaymentEnforcer } from "../src/enforcers/NativeTokenPaymentEnforcer.sol"; -import { NativeTokenTransferAmountEnforcer } from "../src/enforcers/NativeTokenTransferAmountEnforcer.sol"; +import { NativeTokenPeriodTransferEnforcer } from "../src/enforcers/NativeTokenPeriodTransferEnforcer.sol"; import { NativeTokenStreamingEnforcer } from "../src/enforcers/NativeTokenStreamingEnforcer.sol"; +import { NativeTokenTransferAmountEnforcer } from "../src/enforcers/NativeTokenTransferAmountEnforcer.sol"; 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"; @@ -83,6 +85,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new ERC20TransferAmountEnforcer{ salt: salt }()); console2.log("ERC20TransferAmountEnforcer: %s", deployedAddress); + deployedAddress = address(new ERC20PeriodTransferEnforcer{ salt: salt }()); + console2.log("ERC20PeriodTransferEnforcer: %s", deployedAddress); + deployedAddress = address(new ERC20StreamingEnforcer{ salt: salt }()); console2.log("ERC20StreamingEnforcer: %s", deployedAddress); @@ -120,6 +125,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new NativeTokenStreamingEnforcer{ salt: salt }()); console2.log("NativeTokenStreamingEnforcer: %s", deployedAddress); + deployedAddress = address(new NativeTokenPeriodTransferEnforcer{ salt: salt }()); + console2.log("NativeTokenPeriodTransferEnforcer: %s", deployedAddress); + deployedAddress = address(new NonceEnforcer{ salt: salt }()); console2.log("NonceEnforcer: %s", deployedAddress); diff --git a/src/enforcers/ERC20PeriodTransferEnforcer.sol b/src/enforcers/ERC20PeriodTransferEnforcer.sol new file mode 100644 index 00000000..7a4f5236 --- /dev/null +++ b/src/enforcers/ERC20PeriodTransferEnforcer.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode } from "../utils/Types.sol"; + +/** + * @title ERC20PeriodTransferEnforcer + * @notice Enforces periodic transfer limits for ERC20 token transfers. + * @dev This contract implements a mechanism by which a user may transfer up to a fixed amount of tokens (the period amount) + * during a given time period. The transferable amount resets at the beginning of each period, and any unused tokens + * are forfeited once the period ends. Partial transfers within a period are allowed, but the total transfer in any + * period cannot exceed the specified limit. This enforcer is designed to work only in single execution mode (ModeCode.Single). + */ +contract ERC20PeriodTransferEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + ////////////////////////////// State ////////////////////////////// + + struct PeriodicAllowance { + uint256 periodAmount; // Maximum transferable tokens per period. + uint256 periodDuration; // Duration of each period in seconds. + uint256 startDate; // Timestamp when the first period begins. + uint256 lastTransferPeriod; // The period index in which the last transfer was made. + uint256 transferredInCurrentPeriod; // Cumulative amount transferred in the current period. + } + + /** + * @dev Mapping from a delegation manager address and delegation hash to a PeriodicAllowance. + */ + mapping(address delegationManager => mapping(bytes32 delegationHash => PeriodicAllowance)) public periodicAllowances; + + ////////////////////////////// Events ////////////////////////////// + + /** + * @notice Emitted when a transfer is made, updating the transferred amount in the active period. + * @param sender The address initiating the transfer. + * @param recipient The address that receives the tokens. + * @param delegationHash The hash identifying the delegation. + * @param token The ERC20 token contract address. + * @param periodAmount The maximum tokens transferable per period. + * @param periodDuration The duration of each period (in seconds). + * @param startDate The timestamp when the first period begins. + * @param transferredInCurrentPeriod The total tokens transferred in the current period after this transfer. + * @param transferTimestamp The block timestamp at which the transfer was executed. + */ + event TransferredInPeriod( + address indexed sender, + address indexed recipient, + bytes32 indexed delegationHash, + address token, + uint256 periodAmount, + uint256 periodDuration, + uint256 startDate, + uint256 transferredInCurrentPeriod, + uint256 transferTimestamp + ); + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Retrieves the current transferable amount along with period status for a given delegation. + * @param _delegationHash The hash that identifies the delegation. + * @param _delegationManager The address of the delegation manager. + * @param _terms 116 packed bytes: + * - 20 bytes: ERC20 token address. + * - 32 bytes: periodAmount. + * - 32 bytes: periodDuration (in seconds). + * - 32 bytes: startDate for the first period. + * @return availableAmount_ The number of tokens available to transfer in the current period. + * @return isNewPeriod_ A boolean indicating whether a new period has started (i.e., last transfer period differs from current). + * @return currentPeriod_ The current period index based on the start date and period duration. + */ + function getAvailableAmount( + bytes32 _delegationHash, + address _delegationManager, + bytes calldata _terms + ) + external + view + returns (uint256 availableAmount_, bool isNewPeriod_, uint256 currentPeriod_) + { + PeriodicAllowance memory storedAllowance_ = periodicAllowances[_delegationManager][_delegationHash]; + if (storedAllowance_.startDate != 0) { + return _getAvailableAmount(storedAllowance_); + } + + // Not yet initialized: simulate using provided terms. + (, uint256 periodAmount_, uint256 periodDuration_, uint256 startDate_) = getTermsInfo(_terms); + + PeriodicAllowance memory allowance_ = PeriodicAllowance({ + periodAmount: periodAmount_, + periodDuration: periodDuration_, + startDate: startDate_, + lastTransferPeriod: 0, + transferredInCurrentPeriod: 0 + }); + return _getAvailableAmount(allowance_); + } + + /** + * @notice Hook called before an ERC20 transfer to enforce the periodic transfer limit. + * @dev Reverts if the transfer amount exceeds the available tokens for the current period. + * Expects `_terms` to be a 116-byte blob encoding the ERC20 token, period amount, period duration, and start date. + * @param _terms 116 packed bytes: + * - 20 bytes: ERC20 token address. + * - 32 bytes: periodAmount. + * - 32 bytes: periodDuration (in seconds). + * - 32 bytes: startDate for the first period. + * @param _mode The execution mode (must be ModeCode.Single). + * @param _executionCallData The transaction data (should be an `IERC20.transfer(address,uint256)` call). + * @param _delegationHash The hash identifying the delegation. + * @param _redeemer The address intended to receive the tokens. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address _redeemer + ) + public + override + onlySingleExecutionMode(_mode) + { + _validateAndConsumeTransfer(_terms, _executionCallData, _delegationHash, _redeemer); + } + + /** + * @notice Decodes the transfer terms. + * @dev Expects a 116-byte blob and extracts the ERC20 token address, period amount, period duration, and start date. + * @param _terms 116 packed bytes: + * - 20 bytes: ERC20 token address. + * - 32 bytes: periodAmount. + * - 32 bytes: periodDuration (in seconds). + * - 32 bytes: startDate. + * @return token_ The address of the ERC20 token contract. + * @return periodAmount_ The maximum tokens transferable per period. + * @return periodDuration_ The duration of each period in seconds. + * @return startDate_ The timestamp when the first period begins. + */ + function getTermsInfo(bytes calldata _terms) + public + pure + returns (address token_, uint256 periodAmount_, uint256 periodDuration_, uint256 startDate_) + { + require(_terms.length == 116, "ERC20PeriodTransferEnforcer:invalid-terms-length"); + + token_ = address(bytes20(_terms[0:20])); + periodAmount_ = uint256(bytes32(_terms[20:52])); + periodDuration_ = uint256(bytes32(_terms[52:84])); + startDate_ = uint256(bytes32(_terms[84:116])); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @notice Validates and accounts for a transfer by ensuring the transfer amount does not exceed the transferable tokens. + * @dev Uses `_getAvailableAmount` to determine the available transferable amount and whether a new period has started. + * If a new period is detected, the transferred amount is reset before applying the new transfer. + * @param _terms The encoded transfer terms (ERC20 token, period amount, period duration, start date). + * @param _executionCallData The transaction data (expected to be an `IERC20.transfer(address,uint256)` call). + * @param _delegationHash The hash identifying the delegation. + * @param _redeemer The address intended to receive the tokens. + */ + function _validateAndConsumeTransfer( + bytes calldata _terms, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address _redeemer + ) + private + { + (address target_,, bytes calldata callData_) = _executionCallData.decodeSingle(); + + require(callData_.length == 68, "ERC20PeriodTransferEnforcer:invalid-execution-length"); + + (address token_, uint256 periodAmount_, uint256 periodDuration_, uint256 startDate_) = getTermsInfo(_terms); + + // Validate terms + require(startDate_ > 0, "ERC20PeriodTransferEnforcer:invalid-zero-start-date"); + require(periodDuration_ > 0, "ERC20PeriodTransferEnforcer:invalid-zero-period-duration"); + require(periodAmount_ > 0, "ERC20PeriodTransferEnforcer:invalid-zero-period-amount"); + + require(token_ == target_, "ERC20PeriodTransferEnforcer:invalid-contract"); + require(bytes4(callData_[0:4]) == IERC20.transfer.selector, "ERC20PeriodTransferEnforcer:invalid-method"); + + // Ensure the transfer period has started. + require(block.timestamp >= startDate_, "ERC20PeriodTransferEnforcer:transfer-not-started"); + + PeriodicAllowance storage allowance_ = periodicAllowances[msg.sender][_delegationHash]; + + // Initialize the allowance on first use. + if (allowance_.startDate == 0) { + allowance_.periodAmount = periodAmount_; + allowance_.periodDuration = periodDuration_; + allowance_.startDate = startDate_; + allowance_.lastTransferPeriod = 0; + allowance_.transferredInCurrentPeriod = 0; + } + + // Calculate available tokens using the current allowance state. + (uint256 available_, bool isNewPeriod_, uint256 currentPeriod_) = _getAvailableAmount(allowance_); + + uint256 transferAmount_ = uint256(bytes32(callData_[36:68])); + require(transferAmount_ <= available_, "ERC20PeriodTransferEnforcer:transfer-amount-exceeded"); + + // If a new period has started, reset transferred amount before continuing. + if (isNewPeriod_) { + allowance_.lastTransferPeriod = currentPeriod_; + allowance_.transferredInCurrentPeriod = 0; + } + + allowance_.transferredInCurrentPeriod += transferAmount_; + + emit TransferredInPeriod( + msg.sender, + _redeemer, + _delegationHash, + token_, + periodAmount_, + periodDuration_, + allowance_.startDate, + allowance_.transferredInCurrentPeriod, + block.timestamp + ); + } + + /** + * @notice Computes the available tokens that can be transferred in the current period. + * @dev Calculates the current period index based on `startDate` and `periodDuration`. Returns a tuple: + * - availableAmount_: Remaining tokens transferable in the current period. + * - isNewPeriod_: True if the last transfer period is not equal to the current period. + * - currentPeriod_: The current period index, where the first period starts at index 1. + * If the current time is before the start date, availableAmount_ is 0. + * @param _allowance The PeriodicAllowance struct. + * @return availableAmount_ The tokens still available to transfer in the current period. + * @return isNewPeriod_ True if a new period has started since the last transfer. + * @return currentPeriod_ The current period index calculated from the start date. + */ + function _getAvailableAmount(PeriodicAllowance memory _allowance) + internal + view + returns (uint256 availableAmount_, bool isNewPeriod_, uint256 currentPeriod_) + { + if (block.timestamp < _allowance.startDate) { + return (0, false, 0); + } + + currentPeriod_ = (block.timestamp - _allowance.startDate) / _allowance.periodDuration + 1; + + isNewPeriod_ = (_allowance.lastTransferPeriod != currentPeriod_); + + uint256 alreadyTransferred = isNewPeriod_ ? 0 : _allowance.transferredInCurrentPeriod; + + availableAmount_ = _allowance.periodAmount > alreadyTransferred ? _allowance.periodAmount - alreadyTransferred : 0; + } +} diff --git a/src/enforcers/NativeTokenPeriodTransferEnforcer.sol b/src/enforcers/NativeTokenPeriodTransferEnforcer.sol new file mode 100644 index 00000000..05fba930 --- /dev/null +++ b/src/enforcers/NativeTokenPeriodTransferEnforcer.sol @@ -0,0 +1,237 @@ +// 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 NativeTokenPeriodTransferEnforcer + * @notice Enforces periodic transfer limits for native token (ETH) transfers. + * @dev This contract implements a mechanism by which a user may transfer up to a fixed amount of ETH (the period amount) + * during a given time period. The transferable amount resets at the beginning of each period and any unused ETH is + * forfeited once the period ends. Partial transfers within a period are allowed, but the total transfer in any period + * cannot exceed the specified limit. This enforcer is designed to work only in single execution mode (ModeCode.Single). + */ +contract NativeTokenPeriodTransferEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + ////////////////////////////// State ////////////////////////////// + + struct PeriodicAllowance { + uint256 periodAmount; // Maximum transferable ETH (in wei) per period. + uint256 periodDuration; // Duration of each period in seconds. + uint256 startDate; // Timestamp when the first period begins. + uint256 lastTransferPeriod; // The period index in which the last transfer was made. + uint256 transferredInCurrentPeriod; // Cumulative amount transferred in the current period. + } + + /** + * @dev Mapping from a delegation manager address and delegation hash to a PeriodicAllowance. + */ + mapping(address delegationManager => mapping(bytes32 delegationHash => PeriodicAllowance)) public periodicAllowances; + + ////////////////////////////// Events ////////////////////////////// + + /** + * @notice Emitted when a native token transfer is made, updating the transferred amount in the active period. + * @param sender The address initiating the transfer. + * @param redeemer The address that receives the ETH. + * @param delegationHash The hash identifying the delegation. + * @param periodAmount The maximum ETH (in wei) transferable per period. + * @param periodDuration The duration of each period (in seconds). + * @param startDate The timestamp when the first period begins. + * @param transferredInCurrentPeriod The total ETH (in wei) transferred in the current period after this transfer. + * @param transferTimestamp The block timestamp at which the transfer was executed. + */ + event TransferredInPeriod( + address indexed sender, + address indexed redeemer, + bytes32 indexed delegationHash, + uint256 periodAmount, + uint256 periodDuration, + uint256 startDate, + uint256 transferredInCurrentPeriod, + uint256 transferTimestamp + ); + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Retrieves the available ETH by simulating the allowance if it has not been initialized. + * @param _delegationHash The hash identifying the delegation. + * @param _delegationManager The delegation manager address. + * @param _terms 96 packed bytes: + * - 32 bytes: periodAmount. + * - 32 bytes: periodDuration (in seconds). + * - 32 bytes: startDate for the first period. + * @return availableAmount_ The simulated available ETH (in wei) in the current period. + * @return isNewPeriod_ True if a new period would be in effect. + * @return currentPeriod_ The current period index as determined by the terms. + */ + function getAvailableAmount( + bytes32 _delegationHash, + address _delegationManager, + bytes calldata _terms + ) + external + view + returns (uint256 availableAmount_, bool isNewPeriod_, uint256 currentPeriod_) + { + PeriodicAllowance memory storedAllowance_ = periodicAllowances[_delegationManager][_delegationHash]; + if (storedAllowance_.startDate != 0) return _getAvailableAmount(storedAllowance_); + + // Not yet initialized: simulate using provided terms. + (uint256 periodAmount_, uint256 periodDuration_, uint256 startDate_) = getTermsInfo(_terms); + PeriodicAllowance memory allowance_ = PeriodicAllowance({ + periodAmount: periodAmount_, + periodDuration: periodDuration_, + startDate: startDate_, + lastTransferPeriod: 0, + transferredInCurrentPeriod: 0 + }); + return _getAvailableAmount(allowance_); + } + + /** + * @notice Hook called before a native ETH transfer to enforce the periodic transfer limit. + * @dev Reverts if the transfer value exceeds the available ETH for the current period. + * Expects `_terms` to be a 96-byte blob encoding: periodAmount, periodDuration, and startDate. + * @param _terms 96 packed bytes: + * - 32 bytes: periodAmount. + * - 32 bytes: periodDuration (in seconds). + * - 32 bytes: startDate for the first period. + * @param _mode The execution mode (must be ModeCode.Single). + * @param _executionCallData The execution data encoded via ExecutionLib.encodeSingle. + * For native ETH transfers, decodeSingle returns (target, value, callData) and callData is expected to be empty. + * @param _delegationHash The hash identifying the delegation. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address _redeemer + ) + public + override + onlySingleExecutionMode(_mode) + { + _validateAndConsumeTransfer(_terms, _executionCallData, _delegationHash, _redeemer); + } + + /** + * @notice Decodes the native transfer terms. + * @dev Expects a 96-byte blob and extracts: periodAmount, periodDuration, and startDate. + * @param _terms 96 packed bytes: + * - 32 bytes: periodAmount. + * - 32 bytes: periodDuration (in seconds). + * - 32 bytes: startDate. + * @return periodAmount_ The maximum ETH (in wei) transferable per period. + * @return periodDuration_ The duration of each period in seconds. + * @return startDate_ The timestamp when the first period begins. + */ + function getTermsInfo(bytes calldata _terms) + public + pure + returns (uint256 periodAmount_, uint256 periodDuration_, uint256 startDate_) + { + require(_terms.length == 96, "NativeTokenPeriodTransferEnforcer:invalid-terms-length"); + periodAmount_ = uint256(bytes32(_terms[0:32])); + periodDuration_ = uint256(bytes32(_terms[32:64])); + startDate_ = uint256(bytes32(_terms[64:96])); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @notice Validates and consumes a transfer by ensuring the transfer value does not exceed the available ETH. + * @dev Uses _getAvailableAmount to determine the available ETH and whether a new period has started. + * If a new period is detected, the transferred amount is reset before consuming the transfer. + * @param _terms The encoded transfer terms (periodAmount, periodDuration, startDate). + * @param _executionCallData The execution data (expected to be encoded via ExecutionLib.encodeSingle). + * For native transfers, decodeSingle returns (target, value, callData) and callData must be empty. + * @param _delegationHash The hash identifying the delegation. + */ + function _validateAndConsumeTransfer( + bytes calldata _terms, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address _redeemer + ) + private + { + (, uint256 value_,) = _executionCallData.decodeSingle(); + + (uint256 periodAmount_, uint256 periodDuration_, uint256 startDate_) = getTermsInfo(_terms); + + // Validate terms. + require(startDate_ > 0, "NativeTokenPeriodTransferEnforcer:invalid-zero-start-date"); + require(periodDuration_ > 0, "NativeTokenPeriodTransferEnforcer:invalid-zero-period-duration"); + require(periodAmount_ > 0, "NativeTokenPeriodTransferEnforcer:invalid-zero-period-amount"); + + // Ensure the transfer period has started. + require(block.timestamp >= startDate_, "NativeTokenPeriodTransferEnforcer:transfer-not-started"); + + PeriodicAllowance storage allowance_ = periodicAllowances[msg.sender][_delegationHash]; + + // Initialize the allowance on first use. + if (allowance_.startDate == 0) { + allowance_.periodAmount = periodAmount_; + allowance_.periodDuration = periodDuration_; + allowance_.startDate = startDate_; + allowance_.lastTransferPeriod = 0; + allowance_.transferredInCurrentPeriod = 0; + } + + // Calculate available ETH using the current allowance state. + (uint256 available_, bool isNewPeriod_, uint256 currentPeriod_) = _getAvailableAmount(allowance_); + + require(value_ <= available_, "NativeTokenPeriodTransferEnforcer:transfer-amount-exceeded"); + + // If a new period has started, update state before processing the transfer. + if (isNewPeriod_) { + allowance_.lastTransferPeriod = currentPeriod_; + allowance_.transferredInCurrentPeriod = 0; + } + allowance_.transferredInCurrentPeriod += value_; + + emit TransferredInPeriod( + msg.sender, + _redeemer, + _delegationHash, + periodAmount_, + periodDuration_, + allowance_.startDate, + allowance_.transferredInCurrentPeriod, + block.timestamp + ); + } + + /** + * @notice Computes the available ETH that can be transferred in the current period. + * @dev Calculates the current period index based on `startDate` and `periodDuration`. Returns a tuple: + * - availableAmount_: Remaining ETH transferable in the current period. + * - isNewPeriod_: True if the last transfer period is not equal to the current period. + * - currentPeriod_: The current period index, with the first period starting at 1. + * If the current time is before the start date, availableAmount_ is 0. + * @param _allowance The PeriodicAllowance struct. + * @return availableAmount_ The ETH still available to transfer in the current period. + * @return isNewPeriod_ True if a new period has started since the last transfer. + * @return currentPeriod_ The current period index calculated from the start date. + */ + function _getAvailableAmount(PeriodicAllowance memory _allowance) + internal + view + returns (uint256 availableAmount_, bool isNewPeriod_, uint256 currentPeriod_) + { + if (block.timestamp < _allowance.startDate) return (0, false, 0); + currentPeriod_ = (block.timestamp - _allowance.startDate) / _allowance.periodDuration + 1; + isNewPeriod_ = _allowance.lastTransferPeriod != currentPeriod_; + uint256 transferred = isNewPeriod_ ? 0 : _allowance.transferredInCurrentPeriod; + availableAmount_ = _allowance.periodAmount > transferred ? _allowance.periodAmount - transferred : 0; + } +} diff --git a/test/enforcers/ERC20PeriodTransferEnforcer.t.sol b/test/enforcers/ERC20PeriodTransferEnforcer.t.sol new file mode 100644 index 00000000..e2655f39 --- /dev/null +++ b/test/enforcers/ERC20PeriodTransferEnforcer.t.sol @@ -0,0 +1,417 @@ +// 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 { ModeCode, Caveat, Delegation, Execution } from "../../src/utils/Types.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { ERC20PeriodTransferEnforcer } from "../../src/enforcers/ERC20PeriodTransferEnforcer.sol"; +import { BasicERC20, IERC20 } from "../utils/BasicERC20.t.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; + +contract ERC20PeriodTransferEnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + ////////////////////////////// State ////////////////////////////// + ERC20PeriodTransferEnforcer public erc20PeriodTransferEnforcer; + BasicERC20 public basicERC20; + ModeCode public singleMode = ModeLib.encodeSimpleSingle(); + address public alice; + address public bob; + + bytes32 dummyDelegationHash = keccak256("test-delegation"); + address redeemer = address(0x123); + + // Parameters for the periodic allowance. + uint256 periodAmount = 1000; + uint256 periodDuration = 1 days; // 86400 seconds + uint256 startDate; + + ////////////////////// Set up ////////////////////// + function setUp() public override { + super.setUp(); + erc20PeriodTransferEnforcer = new ERC20PeriodTransferEnforcer(); + vm.label(address(erc20PeriodTransferEnforcer), "ERC20 Periodic Claim Enforcer"); + + alice = address(users.alice.deleGator); + bob = address(users.bob.deleGator); + + basicERC20 = new BasicERC20(alice, "TestToken", "TestToken", 100 ether); + + startDate = block.timestamp; // set startDate to current block time + } + + //////////////////// Error / Revert Tests ////////////////////// + + /// @notice Ensures it reverts if _terms length is not exactly 116 bytes. + function testInvalidTermsLength() public { + bytes memory invalidTerms_ = new bytes(115); // one byte short + vm.expectRevert("ERC20PeriodTransferEnforcer:invalid-terms-length"); + erc20PeriodTransferEnforcer.getTermsInfo(invalidTerms_); + } + + /// @notice Reverts if the start date is zero. + function testInvalidZeroStartDate() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, uint256(0)); + bytes memory callData_ = _encodeERC20Transfer(bob, 100); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + vm.expectRevert("ERC20PeriodTransferEnforcer:invalid-zero-start-date"); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if the period duration is zero. + function testInvalidZeroPeriodDuration() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, uint256(0), startDate); + bytes memory callData_ = _encodeERC20Transfer(bob, 100); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + vm.expectRevert("ERC20PeriodTransferEnforcer:invalid-zero-period-duration"); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if the period amount is zero. + function testInvalidZeroPeriodAmount() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), uint256(0), periodDuration, startDate); + bytes memory callData_ = _encodeERC20Transfer(bob, 100); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + vm.expectRevert("ERC20PeriodTransferEnforcer:invalid-zero-period-amount"); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if the transfer period has not started yet. + function testTransferNotStarted() public { + uint256 futureStart_ = block.timestamp + 100; + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, futureStart_); + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + vm.expectRevert("ERC20PeriodTransferEnforcer:transfer-not-started"); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if the execution call data length is not 68 bytes. + function testInvalidExecutionLength() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + bytes memory invalidExecCallData_ = new bytes(67); + vm.expectRevert("ERC20PeriodTransferEnforcer:invalid-execution-length"); + erc20PeriodTransferEnforcer.beforeHook( + terms_, "", singleMode, invalidExecCallData_, dummyDelegationHash, address(0), redeemer + ); + } + + /// @notice Reverts if the target contract in execution data does not match the token in terms_. + function testInvalidContract() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory invalidExecCallData_ = _encodeSingleExecution(address(0xdead), 0, callData_); + vm.expectRevert("ERC20PeriodTransferEnforcer:invalid-contract"); + erc20PeriodTransferEnforcer.beforeHook( + terms_, "", singleMode, invalidExecCallData_, dummyDelegationHash, address(0), redeemer + ); + } + + /// @notice Reverts if the method selector in call data is not for IERC20.transfer. + function testInvalidMethod() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + bytes memory invalidCallData_ = abi.encodeWithSelector(IERC20.transferFrom.selector, redeemer, 500); + bytes memory invalidExecCallData_ = _encodeSingleExecution(address(basicERC20), 0, invalidCallData_); + vm.expectRevert("ERC20PeriodTransferEnforcer:invalid-method"); + erc20PeriodTransferEnforcer.beforeHook( + terms_, "", singleMode, invalidExecCallData_, dummyDelegationHash, address(0), redeemer + ); + } + + /// @notice Reverts if a transfer exceeds the available allowance in the current period. + function testTransferAmountExceeded() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + // First transfer: 800 tokens. + bytes memory callData1_ = _encodeERC20Transfer(bob, 800); + bytes memory execData1_ = _encodeSingleExecution(address(basicERC20), 0, callData1_); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData1_, dummyDelegationHash, address(0), redeemer); + + // Second transfer: attempt to transfer 300 tokens, which exceeds the remaining 200 tokens. + bytes memory callData2_ = _encodeERC20Transfer(bob, 300); + bytes memory execData2_ = _encodeSingleExecution(address(basicERC20), 0, callData2_); + vm.expectRevert("ERC20PeriodTransferEnforcer:transfer-amount-exceeded"); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData2_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Tests a successful transfer and verifies that the TransferredInPeriod event is emitted correctly. + function testSuccessfulTransferAndEvent() public { + uint256 transferAmount_ = 500; + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + bytes memory callData_ = _encodeERC20Transfer(bob, transferAmount_); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectEmit(true, true, true, true); + emit ERC20PeriodTransferEnforcer.TransferredInPeriod( + address(this), + redeemer, + dummyDelegationHash, + address(basicERC20), + periodAmount, + periodDuration, + startDate, + transferAmount_, + block.timestamp + ); + + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + + // Verify available tokens are reduced by the transferred amount. + (uint256 available_,,) = erc20PeriodTransferEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(available_, periodAmount - transferAmount_); + } + + /// @notice Tests multiple transfers within the same period and confirms that an over-transfer reverts. + function testMultipleTransfersInSamePeriod() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + // First transfer: 400 tokens. + bytes memory callData1_ = _encodeERC20Transfer(bob, 400); + bytes memory execData1_ = _encodeSingleExecution(address(basicERC20), 0, callData1_); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData1_, dummyDelegationHash, address(0), bob); + + // Second transfer: 300 tokens. + bytes memory callData2_ = _encodeERC20Transfer(bob, 300); + bytes memory execData2_ = _encodeSingleExecution(address(basicERC20), 0, callData2_); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData2_, dummyDelegationHash, address(0), bob); + + // Available tokens should now be 1000 - 400 - 300 = 300. + (uint256 available_,,) = erc20PeriodTransferEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(available_, 300); + + // Third transfer: attempt to transfer 400 tokens, which should exceed the available amount. + bytes memory callData3_ = _encodeERC20Transfer(bob, 400); + bytes memory execData3_ = _encodeSingleExecution(address(basicERC20), 0, callData3_); + vm.expectRevert("ERC20PeriodTransferEnforcer:transfer-amount-exceeded"); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData3_, dummyDelegationHash, address(0), bob); + } + + /// @notice Tests that the allowance resets when a new period begins. + function testNewPeriodResetsAllowance() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + // First transfer: 800 tokens. + bytes memory callData1_ = _encodeERC20Transfer(bob, 800); + bytes memory execData1_ = _encodeSingleExecution(address(basicERC20), 0, callData1_); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData1_, dummyDelegationHash, address(0), redeemer); + + // Verify available tokens have been reduced. + (uint256 availableAfter1,,) = erc20PeriodTransferEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableAfter1, periodAmount - 800); + + // Warp to the next period. + vm.warp(block.timestamp + periodDuration + 1); + + // Now the available amount should reset to the full periodAmount. + (uint256 available_, bool isNewPeriod_,) = + erc20PeriodTransferEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(available_, periodAmount); + assertTrue(isNewPeriod_); + + // Make a transfer in the new period. + bytes memory callData2_ = _encodeERC20Transfer(bob, 600); + bytes memory execData2_ = _encodeSingleExecution(address(basicERC20), 0, callData2_); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execData2_, dummyDelegationHash, address(0), redeemer); + + // Verify available tokens have been reduced. + (uint256 availableAfter2,,) = erc20PeriodTransferEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableAfter2, periodAmount - 600); + } + + ////////////////////// Integration Tests ////////////////////// + + /// @notice Integration: Successfully transfer tokens within the allowance and update state. + function test_integration_SuccessfulTransfer() public { + uint256 transferAmount_ = 500; + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + // Build execution: transfer transferAmount_ from token to redeemer. + bytes memory callData_ = _encodeERC20Transfer(bob, transferAmount_); + + // Build and sign the delegation. + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(erc20PeriodTransferEnforcer), terms: terms_ }); + Delegation memory delegation = + Delegation({ delegate: bob, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" }); + delegation = signDelegation(users.alice, delegation); + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation); + + // Invoke the user operation via delegation manager. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: address(basicERC20), value: 0, callData: callData_ }) + ); + + // After transferring, available tokens should be reduced. + (uint256 availableAfter_,,) = + erc20PeriodTransferEnforcer.getAvailableAmount(delegationHash_, address(delegationManager), terms_); + assertEq(availableAfter_, periodAmount - transferAmount_, "Available reduced by transfer amount"); + } + + /// @notice Integration: Fails if a transfer exceeds the available tokens in the current period. + function test_integration_OverTransferFails() public { + uint256 transferAmount1_ = 800; + uint256 transferAmount2_ = 300; // total would be 1100, over the periodAmount of 1000 + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + bytes memory callData1_ = _encodeERC20Transfer(bob, transferAmount1_); + + // Build and sign delegation. + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(erc20PeriodTransferEnforcer), terms: terms_ }); + Delegation memory delegation = + Delegation({ delegate: bob, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" }); + delegation = signDelegation(users.alice, delegation); + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation); + + // First transfer succeeds. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: address(basicERC20), value: 0, callData: callData1_ }) + ); + + // Second transfer should revert. + bytes memory callData2_ = _encodeERC20Transfer(bob, transferAmount2_); + bytes memory execCallData2_ = _encodeSingleExecution(address(basicERC20), 0, callData2_); + vm.prank(address(delegationManager)); + vm.expectRevert("ERC20PeriodTransferEnforcer:transfer-amount-exceeded"); + erc20PeriodTransferEnforcer.beforeHook(terms_, "", singleMode, execCallData2_, delegationHash_, address(0), redeemer); + } + + /// @notice Integration: Verifies that the allowance resets in a new period. + function test_integration_NewPeriodReset() public { + uint256 transferAmount_ = 800; + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + bytes memory callData_ = _encodeERC20Transfer(bob, transferAmount_); + + // Build and sign delegation. + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(erc20PeriodTransferEnforcer), terms: terms_ }); + Delegation memory delegation = + Delegation({ delegate: bob, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" }); + delegation = signDelegation(users.alice, delegation); + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation); + + // First transfer in current period. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: address(basicERC20), value: 0, callData: callData_ }) + ); + + (uint256 availableBefore_,, uint256 currentPeriodBefore_) = + erc20PeriodTransferEnforcer.getAvailableAmount(delegationHash_, address(delegationManager), terms_); + assertEq(availableBefore_, periodAmount - transferAmount_, "Allowance reduced after transfer"); + + // Warp to next period. + vm.warp(startDate + periodDuration + 1); + + (uint256 availableAfter_, bool isNewPeriod_, uint256 currentPeriodAfter_) = + erc20PeriodTransferEnforcer.getAvailableAmount(delegationHash_, address(delegationManager), terms_); + assertEq(availableAfter_, periodAmount, "Allowance resets in new period"); + assertTrue(isNewPeriod_, "isNewPeriod_ flag true"); + assertGt(currentPeriodAfter_, currentPeriodBefore_, "Period index increased"); + + // Transfer in new period. + callData_ = _encodeERC20Transfer(bob, 300); + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: address(basicERC20), value: 0, callData: callData_ }) + ); + (uint256 availableAfterTransfer,,) = + erc20PeriodTransferEnforcer.getAvailableAmount(delegationHash_, address(delegationManager), terms_); + assertEq(availableAfterTransfer, periodAmount - 300, "New period allowance reduced by new transfer"); + } + + /// @notice Integration: Confirms that different delegation hashes are tracked independently. + function test_integration_MultipleDelegations() public { + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, startDate); + + // Build two delegations with different salts (hence different hashes). + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(erc20PeriodTransferEnforcer), terms: terms_ }); + Delegation memory delegation1 = + Delegation({ delegate: bob, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" }); + Delegation memory delegation2 = + Delegation({ delegate: bob, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 1, signature: hex"" }); + delegation1 = signDelegation(users.alice, delegation1); + delegation2 = signDelegation(users.alice, delegation2); + // Use the computed delegation hashes. + bytes32 computedDelHash1_ = EncoderLib._getDelegationHash(delegation1); + bytes32 computedDelHash2_ = EncoderLib._getDelegationHash(delegation2); + + // For delegation1, transfer 600 tokens. + bytes memory callData1_ = _encodeERC20Transfer(bob, 600); + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation1), Execution({ target: address(basicERC20), value: 0, callData: callData1_ }) + ); + + // For delegation2, transfer 900 tokens. + bytes memory callData2_ = _encodeERC20Transfer(bob, 900); + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation2), Execution({ target: address(basicERC20), value: 0, callData: callData2_ }) + ); + + (uint256 available1_,,) = + erc20PeriodTransferEnforcer.getAvailableAmount(computedDelHash1_, address(delegationManager), terms_); + (uint256 available2_,,) = + erc20PeriodTransferEnforcer.getAvailableAmount(computedDelHash2_, address(delegationManager), terms_); + assertEq(available1_, periodAmount - 600, "Delegation1 allowance not reduced correctly"); + assertEq(available2_, periodAmount - 900, "Delegation2 allowance not reduced correctly"); + } + + ////////////////////// New Simulation Tests ////////////////////// + + /// @notice Tests simulation of getAvailableAmount before and after the start date. + /// Initially, when the start date is in the future, the available amount is zero. + /// After warping time past the start date, the available amount equals periodAmount. + function test_getAvailableAmountSimulationBeforeInitialization() public { + // Set start date in the future. + uint256 futureStart_ = block.timestamp + 100; + bytes memory terms_ = abi.encodePacked(address(basicERC20), periodAmount, periodDuration, futureStart_); + + // Before the start date, available amount should be 0. + (uint256 availableBefore_, bool isNewPeriodBefore_, uint256 currentPeriodBefore_) = + erc20PeriodTransferEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableBefore_, 0, "Available amount should be zero before start date"); + assertEq(isNewPeriodBefore_, false, "isNewPeriod_ should be false before start date"); + assertEq(currentPeriodBefore_, 0, "Current period should be 0 before start date"); + + // Warp time to after the future start date. + vm.warp(futureStart_ + 1); + + // Now, with no transfers, available amount should equal periodAmount. + (uint256 availableAfter_, bool isNewPeriodAfter_, uint256 currentPeriodAfter_) = + erc20PeriodTransferEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableAfter_, periodAmount, "Available amount should equal periodAmount after start date"); + assertEq(isNewPeriodAfter_, true, "isNewPeriod_ should be true after start date"); + + // Optionally, verify the current period calculation. + uint256 expectedPeriod_ = (block.timestamp - futureStart_) / periodDuration + 1; + assertEq(currentPeriodAfter_, expectedPeriod_, "Current period computed incorrectly after start date"); + } + + ////////////////////// Helper Functions ////////////////////// + + /// @dev Construct the callData for `IERC20.transfer(address,uint256)`. + /// @param _to Recipient of the transfer. + /// @param _amount Amount to transfer. + function _encodeERC20Transfer(address _to, uint256 _amount) internal pure returns (bytes memory) { + return abi.encodeWithSelector(IERC20.transfer.selector, _to, _amount); + } + + /// @dev Construct the callData for `IERC20.transfer(address,uint256)` using a preset redeemer. + /// @param _amount Amount to transfer. + function _encodeERC20Transfer(uint256 _amount) internal view returns (bytes memory) { + return abi.encodeWithSelector(IERC20.transfer.selector, redeemer, _amount); + } + + function _encodeSingleExecution(address _target, uint256 _value, bytes memory _callData) internal pure returns (bytes memory) { + return abi.encodePacked(_target, _value, _callData); + } + + /// @dev Helper to convert a single delegation to an array. + function toDelegationArray(Delegation memory _delegation) internal pure returns (Delegation[] memory) { + Delegation[] memory arr = new Delegation[](1); + arr[0] = _delegation; + return arr; + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(erc20PeriodTransferEnforcer)); + } +} diff --git a/test/enforcers/NativeTokenPeriodTransferEnforcer.t.sol b/test/enforcers/NativeTokenPeriodTransferEnforcer.t.sol new file mode 100644 index 00000000..cc62bb2d --- /dev/null +++ b/test/enforcers/NativeTokenPeriodTransferEnforcer.t.sol @@ -0,0 +1,374 @@ +// 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 { ModeCode, Caveat, Delegation, Execution } from "../../src/utils/Types.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { NativeTokenPeriodTransferEnforcer } from "../../src/enforcers/NativeTokenPeriodTransferEnforcer.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; + +contract NativeTokenPeriodTransferEnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + ////////////////////////////// State ////////////////////////////// + NativeTokenPeriodTransferEnforcer public nativeEnforcer; + ModeCode public singleMode = ModeLib.encodeSimpleSingle(); + address public delegator; + address public redeemer; + address public beneficiary; // target of the ETH transfer + + // We'll use a dummy delegation hash for simulation. + bytes32 public dummyDelegationHash = keccak256("test-delegation"); + + // Parameters for the allowance (in wei). + uint256 public periodAmount = 1 ether; + uint256 public periodDuration = 1 days; // 86400 seconds + uint256 public startDate; + + ////////////////////// Set up ////////////////////// + function setUp() public override { + super.setUp(); + nativeEnforcer = new NativeTokenPeriodTransferEnforcer(); + vm.label(address(nativeEnforcer), "Native Token Period Allowance Enforcer"); + + // For testing, we use these addresses. + delegator = address(users.alice.deleGator); + + redeemer = address(users.bob.deleGator); + beneficiary = address(users.bob.deleGator); + + // Set the start date to the current time. + startDate = block.timestamp; + + // Give the delegator an initial ETH balance. + vm.deal(delegator, 100 ether); + } + + //////////////////// Error / Revert Tests ////////////////////// + + /// @notice Ensures it reverts if _terms length is not exactly 96 bytes. + function testInvalidTermsLength() public { + bytes memory invalidTerms_ = new bytes(95); // one byte short + vm.expectRevert("NativeTokenPeriodTransferEnforcer:invalid-terms-length"); + nativeEnforcer.getTermsInfo(invalidTerms_); + } + + /// @notice Reverts if the start date is zero. + function testInvalidZeroStartDate() public { + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, uint256(0)); + // Build execution call data: encode native transfer with beneficiary as target and 0.5 ether value. + bytes memory execData_ = _encodeNativeTransfer(beneficiary, 0.5 ether); + vm.expectRevert("NativeTokenPeriodTransferEnforcer:invalid-zero-start-date"); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if the period duration is zero. + function testInvalidZeroPeriodDuration() public { + bytes memory terms_ = abi.encodePacked(periodAmount, uint256(0), startDate); + bytes memory execData_ = _encodeNativeTransfer(beneficiary, 0.5 ether); + vm.expectRevert("NativeTokenPeriodTransferEnforcer:invalid-zero-period-duration"); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if the period amount is zero. + function testInvalidZeroPeriodAmount() public { + bytes memory terms_ = abi.encodePacked(uint256(0), periodDuration, startDate); + bytes memory execData_ = _encodeNativeTransfer(beneficiary, 0.5 ether); + vm.expectRevert("NativeTokenPeriodTransferEnforcer:invalid-zero-period-amount"); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if the transfer period has not started yet. + function testTransferNotStarted() public { + uint256 futureStart_ = block.timestamp + 100; + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, futureStart_); + bytes memory execData_ = _encodeNativeTransfer(beneficiary, 0.5 ether); + vm.expectRevert("NativeTokenPeriodTransferEnforcer:transfer-not-started"); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Reverts if a transfer exceeds the available ETH allowance. + function testTransferAmount_Exceeded() public { + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + // First transfer: 0.8 ether. + bytes memory execData1_ = _encodeNativeTransfer(beneficiary, 0.8 ether); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData1_, dummyDelegationHash, address(0), redeemer); + + // Second transfer: attempt to transfer 0.3 ether, which exceeds remaining 0.2 ether. + bytes memory execData2_ = _encodeNativeTransfer(beneficiary, 0.3 ether); + vm.expectRevert("NativeTokenPeriodTransferEnforcer:transfer-amount-exceeded"); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData2_, dummyDelegationHash, address(0), redeemer); + } + + ////////////////////// Successful and Multiple Transfers ////////////////////// + + /// @notice Tests a successful native ETH transfer and verifies that the TransferredInPeriod event is emitted. + function testSuccessfulTransferAndEvent() public { + uint256 transferAmount_ = 0.5 ether; + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + bytes memory execData_ = _encodeNativeTransfer(beneficiary, transferAmount_); + + vm.expectEmit(true, true, true, true); + emit NativeTokenPeriodTransferEnforcer.TransferredInPeriod( + address(this), redeemer, dummyDelegationHash, periodAmount, periodDuration, startDate, transferAmount_, block.timestamp + ); + + nativeEnforcer.beforeHook(terms_, "", singleMode, execData_, dummyDelegationHash, address(0), redeemer); + + (uint256 availableAfter_,,) = nativeEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableAfter_, periodAmount - transferAmount_, "Available reduced by transfer"); + } + + /// @notice Tests multiple native ETH transfers within the same period and confirms that an over-transfer reverts. + function testMultipleTransfersInSamePeriod() public { + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + // First transfer: 0.4 ether. + bytes memory execData1_ = _encodeNativeTransfer(beneficiary, 0.4 ether); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData1_, dummyDelegationHash, address(0), redeemer); + + // Second transfer: 0.3 ether. + bytes memory execData2_ = _encodeNativeTransfer(beneficiary, 0.3 ether); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData2_, dummyDelegationHash, address(0), redeemer); + + (uint256 available_,,) = nativeEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + // Expected remaining: 1 ether - 0.4 ether - 0.3 ether = 0.3 ether. + assertEq(available_, 0.3 ether, "Remaining allowance should be 0.3 ETH"); + + // Third transfer: attempt to transfer 0.4 ether, which should revert. + bytes memory execData3_ = _encodeNativeTransfer(beneficiary, 0.4 ether); + vm.expectRevert("NativeTokenPeriodTransferEnforcer:transfer-amount-exceeded"); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData3_, dummyDelegationHash, address(0), redeemer); + } + + /// @notice Tests that the allowance resets when a new period begins. + function testNewPeriodResetsAllowance() public { + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + // First transfer: 0.8 ether. + bytes memory execData1_ = _encodeNativeTransfer(beneficiary, 0.8 ether); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData1_, dummyDelegationHash, address(0), redeemer); + + (uint256 availableBefore_,, uint256 periodBefore_) = + nativeEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableBefore_, periodAmount - 0.8 ether, "Allowance reduced after transfer"); + + // Warp time to next period. + vm.warp(startDate + periodDuration + 1); + (uint256 availableAfter_, bool isPeriodNew_, uint256 periodAfter_) = + nativeEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableAfter_, periodAmount, "Allowance resets in new period"); + assertTrue(isPeriodNew_, "isNewPeriod flag true"); + assertGt(periodAfter_, periodBefore_, "Period index increased"); + + // Transfer in new period: 0.3 ether. + bytes memory execData2_ = _encodeNativeTransfer(beneficiary, 0.3 ether); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData2_, dummyDelegationHash, address(0), redeemer); + (uint256 availableAfterTransfer_,,) = nativeEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableAfterTransfer_, periodAmount - 0.3 ether, "New period allowance reduced by new transfer"); + } + + ////////////////////// Integration Tests ////////////////////// + + /// @notice Integration: Simulates a full native ETH transfer via delegation and verifies allowance update. + function test_integration_SuccessfulTransfer() public { + uint256 transferAmount_ = 0.5 ether; + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + + // Build and sign the delegation. + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(nativeEnforcer), terms: terms_ }); + Delegation memory delegation = Delegation({ + delegate: beneficiary, + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation = signDelegation(users.alice, delegation); + bytes32 delHash = EncoderLib._getDelegationHash(delegation); + + // Simulate a native transfer user operation. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: beneficiary, value: transferAmount_, callData: hex"" }) + ); + + (uint256 availableAfter_,,) = nativeEnforcer.getAvailableAmount(delHash, address(delegationManager), terms_); + assertEq(availableAfter_, periodAmount - transferAmount_, "Available reduced by transfer"); + } + + /// @notice Integration: Fails if a native transfer exceeds the available ETH allowance. + function test_integration_OverTransferFails() public { + uint256 transferAmount_1 = 0.8 ether; + uint256 transferAmount_2 = 0.3 ether; // total 1.1 ether, exceeds periodAmount = 1 ether + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + + // Build and sign delegation. + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(nativeEnforcer), terms: terms_ }); + Delegation memory delegation = Delegation({ + delegate: beneficiary, + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation = signDelegation(users.alice, delegation); + bytes32 delHash = EncoderLib._getDelegationHash(delegation); + + // First transfer succeeds. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: beneficiary, value: transferAmount_1, callData: hex"" }) + ); + + // Second transfer should revert. + bytes memory execData2_ = _encodeNativeTransfer(beneficiary, transferAmount_2); + vm.prank(address(delegationManager)); + vm.expectRevert("NativeTokenPeriodTransferEnforcer:transfer-amount-exceeded"); + nativeEnforcer.beforeHook(terms_, "", singleMode, execData2_, delHash, address(0), redeemer); + } + + /// @notice Integration: Verifies that the allowance resets in a new period for native transfers. + function test_integration_NewPeriodReset() public { + uint256 transferAmount_ = 0.8 ether; + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + + // Build and sign delegation. + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(nativeEnforcer), terms: terms_ }); + Delegation memory delegation = Delegation({ + delegate: beneficiary, + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation = signDelegation(users.alice, delegation); + bytes32 delHash = EncoderLib._getDelegationHash(delegation); + + // First transfer in current period. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: beneficiary, value: transferAmount_, callData: hex"" }) + ); + + (uint256 availableBefore_,, uint256 periodBefore_) = + nativeEnforcer.getAvailableAmount(delHash, address(delegationManager), terms_); + assertEq(availableBefore_, periodAmount - transferAmount_, "Allowance reduced after transfer"); + + // Warp to next period. + vm.warp(startDate + periodDuration + 1); + (uint256 availableAfter_, bool isNew, uint256 periodAfter_) = + nativeEnforcer.getAvailableAmount(delHash, address(delegationManager), terms_); + assertEq(availableAfter_, periodAmount, "Allowance resets in new period"); + assertTrue(isNew, "isNewPeriod flag true"); + assertGt(periodAfter_, periodBefore_, "Period index increased"); + + // Transfer in new period. + uint256 newTransfer_ = 0.3 ether; + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation), Execution({ target: beneficiary, value: newTransfer_, callData: hex"" }) + ); + (uint256 availableAfterTransfer_,,) = nativeEnforcer.getAvailableAmount(delHash, address(delegationManager), terms_); + assertEq(availableAfterTransfer_, periodAmount - newTransfer_, "New period allowance reduced by new transfer"); + } + + /// @notice Integration: Confirms that different delegation hashes are tracked independently. + function test_integration_MultipleDelegations() public { + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, startDate); + + // Build two delegations with different salts (thus different hashes). + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(nativeEnforcer), terms: terms_ }); + Delegation memory delegation1_ = Delegation({ + delegate: beneficiary, + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + Delegation memory delegation2_ = Delegation({ + delegate: beneficiary, + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 1, + signature: hex"" + }); + delegation1_ = signDelegation(users.alice, delegation1_); + delegation2_ = signDelegation(users.alice, delegation2_); + bytes32 computedDelHash1_ = EncoderLib._getDelegationHash(delegation1_); + bytes32 computedDelHash2_ = EncoderLib._getDelegationHash(delegation2_); + + // For delegation1_, transfer 0.6 ether. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation1_), Execution({ target: beneficiary, value: 0.6 ether, callData: hex"" }) + ); + + // For delegation2_, transfer 0.9 ether. + invokeDelegation_UserOp( + users.bob, toDelegationArray(delegation2_), Execution({ target: beneficiary, value: 0.9 ether, callData: hex"" }) + ); + + (uint256 available1_,,) = nativeEnforcer.getAvailableAmount(computedDelHash1_, address(delegationManager), terms_); + (uint256 available2_,,) = nativeEnforcer.getAvailableAmount(computedDelHash2_, address(delegationManager), terms_); + assertEq(available1_, periodAmount - 0.6 ether, "Delegation1 allowance updated correctly"); + assertEq(available2_, periodAmount - 0.9 ether, "Delegation2 allowance updated correctly"); + } + + ////////////////////// New Simulation Tests ////////////////////// + + /// @notice Tests simulation of getAvailableAmount when no allowance is stored. + /// Initially, if the start date is in the future, available amount is zero; + /// after warping time past the start, available equals periodAmount. + function test_getAvailableAmountSimulationBeforeInitialization() public { + uint256 futureStart_ = block.timestamp + 100; + bytes memory terms_ = abi.encodePacked(periodAmount, periodDuration, futureStart_); + + // Before the start date, available should be 0. + (uint256 availableBefore_, bool isNewPeriodBefore, uint256 currentPeriodBefore) = + nativeEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableBefore_, 0, "Available should be 0 before start"); + assertEq(isNewPeriodBefore, false, "isNewPeriod false before start"); + assertEq(currentPeriodBefore, 0, "Period index 0 before start"); + + // Warp time to after the start date. + vm.warp(futureStart_ + 1); + + // Now, with no transfer made, available amount should equal periodAmount. + (uint256 availableAfter_, bool isNewPeriodAfter_, uint256 currentPeriodAfter_) = + nativeEnforcer.getAvailableAmount(dummyDelegationHash, address(this), terms_); + assertEq(availableAfter_, periodAmount, "Available equals periodAmount after start"); + + // Since no transfer was made, lastTransferPeriod remains 0 so currentPeriod should be > 0 => isNewPeriodAfter_ true. + assertTrue(isNewPeriodAfter_, "isNewPeriod should be true after start"); + + // Optionally, verify the current period calculation. + uint256 expectedPeriod_ = (block.timestamp - futureStart_) / periodDuration + 1; + assertEq(currentPeriodAfter_, expectedPeriod_, "Current period computed incorrectly after start date"); + } + + ////////////////////// Helper Functions ////////////////////// + + /// @dev Constructs the execution call data for a native ETH transfer. + /// It encodes the target and value; callData is expected to be empty. + function _encodeNativeTransfer(address _target, uint256 _value) internal pure returns (bytes memory) { + return abi.encodePacked(_target, _value, ""); // target (20 bytes) + value (32 bytes) + empty callData + } + + /// @dev Helper to convert a single delegation to an array. + function toDelegationArray(Delegation memory _delegation) internal pure returns (Delegation[] memory) { + Delegation[] memory arr = new Delegation[](1); + arr[0] = _delegation; + return arr; + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(nativeEnforcer)); + } +}