From 17094f2c4393c34f7e0a80cd094856e02741be61 Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Fri, 14 Feb 2025 15:13:27 -0600 Subject: [PATCH 1/4] Added erc20 streaming enforcer --- .github/workflows/test.yml | 2 +- script/DeployCaveatEnforcers.s.sol | 8 +- src/enforcers/StreamingERC20Enforcer.sol | 274 ++++++++++++++ test/enforcers/StreamingERC20Enforcer.t.sol | 396 ++++++++++++++++++++ 4 files changed, 675 insertions(+), 5 deletions(-) create mode 100644 src/enforcers/StreamingERC20Enforcer.sol create mode 100644 test/enforcers/StreamingERC20Enforcer.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49a4107a..ba47f2ab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: run: forge install - name: Check contract sizes - run: forge build --sizes --skip test --skip script + run: forge build --sizes --skip test --skip script --optimize true - name: Run tests run: forge test -vvv diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index c52ce022..98c09159 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.23; import "forge-std/Script.sol"; import { console2 } from "forge-std/console2.sol"; -import { IEntryPoint } from "@account-abstraction/interfaces/IEntryPoint.sol"; import { IDelegationManager } from "../src/interfaces/IDelegationManager.sol"; @@ -26,6 +25,7 @@ import { NativeTokenTransferAmountEnforcer } from "../src/enforcers/NativeTokenT import { NonceEnforcer } from "../src/enforcers/NonceEnforcer.sol"; import { OwnershipTransferEnforcer } from "../src/enforcers/OwnershipTransferEnforcer.sol"; import { RedeemerEnforcer } from "../src/enforcers/RedeemerEnforcer.sol"; +import { StreamingERC20Enforcer } from "../src/enforcers/StreamingERC20Enforcer.sol"; import { TimestampEnforcer } from "../src/enforcers/TimestampEnforcer.sol"; import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; @@ -38,19 +38,16 @@ import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; */ contract DeployCaveatEnforcers is Script { bytes32 salt; - IEntryPoint entryPoint; IDelegationManager delegationManager; address deployer; function setUp() public { salt = bytes32(abi.encodePacked(vm.envString("SALT"))); - entryPoint = IEntryPoint(vm.envAddress("ENTRYPOINT_ADDRESS")); delegationManager = IDelegationManager(vm.envAddress("DELEGATION_MANAGER_ADDRESS")); deployer = msg.sender; console2.log("~~~"); console2.log("Deployer: %s", address(deployer)); - console2.log("Entry Point: %s", address(entryPoint)); console2.log("Delegation Manager: %s", address(delegationManager)); console2.log("Salt:"); console2.logBytes32(salt); @@ -120,6 +117,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new RedeemerEnforcer{ salt: salt }()); console2.log("RedeemerEnforcer: %s", deployedAddress); + deployedAddress = address(new StreamingERC20Enforcer{ salt: salt }()); + console2.log("StreamingERC20Enforcer: %s", deployedAddress); + deployedAddress = address(new TimestampEnforcer{ salt: salt }()); console2.log("TimestampEnforcer: %s", deployedAddress); diff --git a/src/enforcers/StreamingERC20Enforcer.sol b/src/enforcers/StreamingERC20Enforcer.sol new file mode 100644 index 00000000..f508f2d5 --- /dev/null +++ b/src/enforcers/StreamingERC20Enforcer.sol @@ -0,0 +1,274 @@ +// 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 StreamingERC20Enforcer + * @notice This contract enforces a streaming transfer limit for ERC20 tokens. + * + * How it works: + * - `maxAmount` is a hard cap on total tokens that can ever become available. + * - If `initialAmount` == 0, the allowance accumulates linearly from `startTime` + * at a rate of `amountPerSecond`. + * - If `initialAmount` > 0, then the allowance is unlocked in "chunks": + * - The first chunk (size = `initialAmount`) is available immediately at `startTime`. + * - Each subsequent chunk is also `initialAmount` in size, and becomes available + * after each `chunkDuration = (initialAmount / amountPerSecond)` seconds. + * + * @dev This caveat enforcer only works when the execution is in single mode (`ModeCode.Single`). + */ +contract StreamingERC20Enforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + ////////////////////////////// State ////////////////////////////// + + struct StreamingAllowance { + uint256 initialAmount; + uint256 maxAmount; + uint256 amountPerSecond; + uint256 startTime; + uint256 spent; + } + + /** + * @dev Maps a delegation manager address and delegation hash to a StreamingAllowance. + */ + mapping(address delegationManager => mapping(bytes32 delegationHash => StreamingAllowance)) public streamingAllowances; + + ////////////////////////////// Events ////////////////////////////// + + event IncreasedSpentMap( + address indexed sender, + address indexed redeemer, + bytes32 indexed delegationHash, + address token, + uint256 initialAmount, + uint256 maxAmount, + uint256 amountPerSecond, + uint256 startTime, + uint256 spent, + uint256 lastUpdateTimestamp + ); + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Retrieves the current available allowance for a specific delegation. + * @param _delegationHash The hash of the delegation being queried. + * @param _delegationManager The address of the delegation manager. + * @return availableAmount_ The number of tokens that are currently spendable + * under this streaming allowance (capped by `maxAmount`). + */ + function getAvailableAmount( + bytes32 _delegationHash, + address _delegationManager + ) + external + view + returns (uint256 availableAmount_) + { + StreamingAllowance storage allowance = streamingAllowances[_delegationManager][_delegationHash]; + availableAmount_ = _getAvailableAmount(allowance); + } + + /** + * @notice Hook called before an ERC20 transfer is executed to enforce streaming limits. + * @dev This function will revert if the transfer amount exceeds the available streaming allowance. + * @param _terms 148 packed bytes where: + * - 20 bytes: ERC20 token address. + * - 32 bytes: initial amount. + * - 32 bytes: max amount. + * - 32 bytes: amount per second. + * - 32 bytes: start time for the streaming allowance. + * @param _mode The mode of the execution (must be `ModeCode.Single` for this enforcer). + * @param _executionCallData The transaction the delegate might try to perform. + * @param _delegationHash The hash of the delegation being operated on. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address _redeemer + ) + public + override + onlySingleExecutionMode(_mode) + { + _validateAndConsumeAllowance(_terms, _executionCallData, _delegationHash, _redeemer); + } + + /** + * @notice Decodes the terms used in this CaveatEnforcer. + * @param _terms 148 packed bytes where: + * - 20 bytes: ERC20 token address. + * - 32 bytes: initial amount. + * - 32 bytes: max amount. + * - 32 bytes: amount per second. + * - 32 bytes: start time for the streaming allowance. + * @return token_ The address of the ERC20 token contract. + * @return initialAmount_ The initial chunk size or 0 if purely linear + * @return maxAmount_ The maximum total unlocked tokens (hard cap) + * @return amountPerSecond_ The rate at which the allowance increases per second. + * @return startTime_ The timestamp from which the allowance streaming begins. + */ + function getTermsInfo(bytes calldata _terms) + public + pure + returns (address token_, uint256 initialAmount_, uint256 maxAmount_, uint256 amountPerSecond_, uint256 startTime_) + { + require(_terms.length == 148, "StreamingERC20Enforcer:invalid-terms-length"); + + token_ = address(bytes20(_terms[0:20])); + initialAmount_ = uint256(bytes32(_terms[20:52])); + maxAmount_ = uint256(bytes32(_terms[52:84])); + amountPerSecond_ = uint256(bytes32(_terms[84:116])); + startTime_ = uint256(bytes32(_terms[116:148])); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @notice Enforces the streaming allowance limit and updates `spent`. + * @dev Reverts if the transfer amount exceeds the currently available allowance. + * + * @param _terms The encoded streaming terms: ERC20 token, initial amount, amount per second, and start time. + * @param _executionCallData The transaction data specifying the target contract and call data. We expect + * an `IERC20.transfer(address,uint256)` call here. + * @param _delegationHash The hash of the delegation to which this transfer applies. + * @return token_ The token address (extracted from `_terms`). + * @return initialAmount_ The `initialAmount` set for this streaming allowance. + * @return maxAmount_ The maximum amount that can be transferred. + * @return amountPerSecond_ The streaming rate specified in `_terms`. + * @return startTime_ The timestamp after which tokens become available. + * @return spent_ The updated `spent` amount after applying this transfer. + */ + function _validateAndConsumeAllowance( + bytes calldata _terms, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address _redeemer + ) + private + returns ( + address token_, + uint256 initialAmount_, + uint256 maxAmount_, + uint256 amountPerSecond_, + uint256 startTime_, + uint256 spent_ + ) + { + (address target_,, bytes calldata callData_) = _executionCallData.decodeSingle(); + + require(callData_.length == 68, "StreamingERC20Enforcer:invalid-execution-length"); + + (token_, initialAmount_, maxAmount_, amountPerSecond_, startTime_) = getTermsInfo(_terms); + + require(maxAmount_ >= initialAmount_, "StreamingERC20Enforcer:invalid-max-amount"); + + require(startTime_ > 0, "StreamingERC20Enforcer:invalid-zero-start-time"); + + require(token_ == target_, "StreamingERC20Enforcer:invalid-contract"); + + require(bytes4(callData_[0:4]) == IERC20.transfer.selector, "StreamingERC20Enforcer:invalid-method"); + + StreamingAllowance storage allowance = streamingAllowances[msg.sender][_delegationHash]; + if (allowance.spent == 0) { + // First use of this delegation + allowance.initialAmount = initialAmount_; + allowance.maxAmount = maxAmount_; + allowance.amountPerSecond = amountPerSecond_; + allowance.startTime = startTime_; + } + + uint256 transferAmount_ = uint256(bytes32(callData_[36:68])); + + require(transferAmount_ <= _getAvailableAmount(allowance), "StreamingERC20Enforcer:allowance-exceeded"); + + allowance.spent += transferAmount_; + spent_ = allowance.spent; + + emit IncreasedSpentMap( + msg.sender, + _redeemer, + _delegationHash, + token_, + initialAmount_, + maxAmount_, + amountPerSecond_, + startTime_, + spent_, + block.timestamp + ); + } + + /** + * @notice Calculates the available allowance for a given StreamingAllowance state. + * @dev Computes the remaining allowance based on elapsed time, initial amount, and spent tokens + * then clamps by `maxAmount`. + * @param allowance The StreamingAllowance struct containing allowance details. + * @return A uint256 representing how many tokens are currently available to spend. + */ + function _getAvailableAmount(StreamingAllowance storage allowance) internal view returns (uint256) { + if (block.timestamp < allowance.startTime) return 0; + + uint256 elapsed_ = block.timestamp - allowance.startTime; + + // If `initialAmount` == 0, do purely linear streaming + if (allowance.initialAmount == 0) return _computeLinearAllowance(allowance, elapsed_); + + require(allowance.amountPerSecond > 0, "StreamingERC20Enforcer:zero-amount-per-second"); + + // If the user wants chunks, ensure the initial amount is large enough + // that `chunkDuration` won't be zero. + require(allowance.initialAmount >= allowance.amountPerSecond, "StreamingERC20Enforcer:initial-amount-is-too-low"); + + // Calculate how many chunks have fully unlocked + uint256 chunkDuration_ = allowance.initialAmount / allowance.amountPerSecond; + uint256 chunksUnlocked_ = elapsed_ / chunkDuration_; + + // The first chunk is unlocked immediately at `startTime`, + uint256 totalUnlocked_ = (chunksUnlocked_ + 1) * allowance.initialAmount; + + // clamp by maxAmount + if (totalUnlocked_ > allowance.maxAmount) { + totalUnlocked_ = allowance.maxAmount; + } + + if (allowance.spent >= totalUnlocked_) return 0; + + return totalUnlocked_ - allowance.spent; + } + + /** + * @notice Computes the unlocked amount using a purely linear model: + * `initialAmount + (amountPerSecond * elapsed)`, then clamps by `maxAmount`. + * + * @dev This function is called when `initialAmount == 0`, or as a fallback + * if chunk-based logic is not feasible. + * + * @param allowance The StreamingAllowance containing the streaming parameters. + * @param elapsed_ How many seconds have passed since `startTime`. + * @return The number of tokens currently available after subtracting `spent`. + */ + function _computeLinearAllowance(StreamingAllowance storage allowance, uint256 elapsed_) private view returns (uint256) { + uint256 totalSoFar_ = allowance.initialAmount + (allowance.amountPerSecond * elapsed_); + + // clamp to maxAmount + if (totalSoFar_ > allowance.maxAmount) { + totalSoFar_ = allowance.maxAmount; + } + + if (allowance.spent >= totalSoFar_) return 0; + + return totalSoFar_ - allowance.spent; + } +} diff --git a/test/enforcers/StreamingERC20Enforcer.t.sol b/test/enforcers/StreamingERC20Enforcer.t.sol new file mode 100644 index 00000000..0eb112b1 --- /dev/null +++ b/test/enforcers/StreamingERC20Enforcer.t.sol @@ -0,0 +1,396 @@ +// 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 } from "../../src/utils/Types.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { StreamingERC20Enforcer } from "../../src/enforcers/StreamingERC20Enforcer.sol"; +import { BasicERC20, IERC20 } from "../utils/BasicERC20.t.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; + +contract StreamingERC20EnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + ////////////////////////////// State ////////////////////////////// + StreamingERC20Enforcer public streamingERC20Enforcer; + BasicERC20 public basicERC20; + ModeCode public mode = ModeLib.encodeSimpleSingle(); + address public alice; + address public bob; + + ////////////////////// Set up ////////////////////// + + function setUp() public override { + super.setUp(); + streamingERC20Enforcer = new StreamingERC20Enforcer(); + vm.label(address(streamingERC20Enforcer), "Streaming ERC20 Enforcer"); + + alice = address(users.alice.deleGator); + bob = address(users.bob.deleGator); + + basicERC20 = new BasicERC20(alice, "TestToken", "TestToken", 100 ether); + } + + //////////////////// Error / Revert Tests ////////////////////// + + /** + * @notice Ensures it reverts if `_terms.length != 148`. + */ + function test_invalidTermsLength() public { + // Provide fewer than 148 bytes + bytes memory badTerms = new bytes(100); + + // Minimal callData_ + bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-terms-length")); + streamingERC20Enforcer.beforeHook(badTerms, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Checks revert if `maxAmount < initialAmount`. + */ + function test_invalidMaxAmount() public { + // initial=100, max=50 => revert + bytes memory terms = encodeTerms( + address(basicERC20), + 100 ether, // initial + 50 ether, // max < initial + 1 ether, + block.timestamp + 10 + ); + + bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-max-amount")); + streamingERC20Enforcer.beforeHook(terms, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Test that it reverts if startTime == 0. + */ + function test_invalidZeroStartTime() public { + // Prepare valid token and amounts, but zero start time + uint256 startTime_ = 0; + bytes memory terms_ = encodeTerms(address(basicERC20), 10 ether, 100 ether, 1 ether, startTime_); + + bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-zero-start-time")); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Test that it reverts with `StreamingERC20Enforcer:allowance-exceeded` + * if the transfer request exceeds the currently unlocked amount. + */ + function test_allowanceExceeded() public { + // Start in the future => 0 available now + uint256 start_ = block.timestamp + 100; + bytes memory terms_ = encodeTerms(address(basicERC20), 10 ether, 50 ether, 1 ether, start_); + + // Trying to transfer more than is available (which is 0 if we call now). + bytes memory callData_ = encodeERC20Transfer(bob, 50 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:allowance-exceeded")); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Test chunk logic revert if `initialAmount` > 0 but `amountPerSecond=0`. + */ + function test_zeroAmountPerSecondChunkLogic() public { + bytes memory terms_ = encodeTerms( + address(basicERC20), + 100 ether, // initial + 500 ether, // max + 0, // amountPerSecond=0 + block.timestamp + ); + + // The call data is valid + bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + // Because initialAmount > 0 and amountPerSecond = 0, chunk logic triggers the revert + vm.warp(block.timestamp + 1); + vm.expectRevert(bytes("StreamingERC20Enforcer:zero-amount-per-second")); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /// @notice Test chunk logic revert if initialAmount < amountPerSecond. + function test_initialAmountTooLow() public { + // initial=1, rate=2 => revert + bytes memory terms_ = encodeTerms(address(basicERC20), 1 ether, 10 ether, 2 ether, block.timestamp); + + bytes memory callData_ = encodeERC20Transfer(bob, 1 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:initial-amount-is-too-low")); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /// @notice Test that it reverts with `StreamingERC20Enforcer:invalid-execution-length` if the callData_ is not 68 bytes. + function test_invalidExecutionLength() public { + // valid `_terms` + bytes memory terms_ = encodeTerms(address(basicERC20), 100 ether, 1 ether, 1 ether, block.timestamp + 10); + // Provide some random data that is not exactly 68 bytes + bytes memory callData_ = new bytes(40); + // encodeSingleExecution + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-execution-length")); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /// @notice Test that it reverts with `StreamingERC20Enforcer:invalid-method` if the selector isn't `transfer`. + function test_invalidMethodSelector() public { + bytes memory terms_ = encodeTerms(address(basicERC20), 100 ether, 100 ether, 1 ether, block.timestamp + 10); + + // Calling transferFrom() method instead of the valid transfer method + bytes memory badCallData_ = abi.encodeWithSelector(IERC20.transferFrom.selector, bob, 10 ether); + + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, badCallData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-method")); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /// @notice Test that it reverts with `StreamingERC20Enforcer:invalid-contract` if the token address doesn't match the target. + function test_invalidContract() public { + // Terms says the token is `basicERC20`, but we call a different target in `execData_` + bytes memory terms_ = encodeTerms(address(basicERC20), 100 ether, 100 ether, 1 ether, block.timestamp + 10); + + // Encode callData_ with correct selector but to a different contract address + BasicERC20 otherToken_ = new BasicERC20(alice, "TestToken2", "TestToken2", 100 ether); + bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = encodeSingleExecution(address(otherToken_), 0, callData_); + + vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-contract")); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + //////////////////// Valid cases ////////////////////// + + /// @notice Test that getTermsInfo() decodes valid 148-byte terms correctly. + function test_getTermsInfoHappyPath() public { + address token_ = address(basicERC20); + uint256 initialAmount_ = 100 ether; + uint256 maxAmount_ = 50 ether; + uint256 amountPerSecond_ = 1 ether; + uint256 startTime_ = block.timestamp + 100; + + bytes memory termsData_ = encodeTerms(token_, initialAmount_, maxAmount_, amountPerSecond_, startTime_); + + ( + address decodedToken_, + uint256 decodedInitialAmount_, + uint256 decodedMaxAmount_, + uint256 decodedAmountPerSecond_, + uint256 decodedStartTime_ + ) = streamingERC20Enforcer.getTermsInfo(termsData_); + + assertEq(decodedToken_, token_, "Token mismatch"); + assertEq(decodedInitialAmount_, initialAmount_, "Initial amount mismatch"); + assertEq(decodedMaxAmount_, maxAmount_, "Max amount mismatch"); + assertEq(decodedAmountPerSecond_, amountPerSecond_, "Amount per second mismatch"); + assertEq(decodedStartTime_, startTime_, "Start time mismatch"); + } + + /// @notice Test that getTermsInfo() reverts with `StreamingERC20Enforcer:invalid-terms-length` if `_terms` is not 148 bytes. + function test_getTermsInfoInvalidLength() public { + // Create an array shorter than 1 bytes + bytes memory shortTermsData = new bytes(100); + + // Expect the specific revert + vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-terms-length")); + streamingERC20Enforcer.getTermsInfo(shortTermsData); + } + + /** + * @notice Confirms the `IncreasedSpentMap` event is emitted for a valid transfer. + */ + function test_increasedSpentMapEvent() public { + uint256 initialAmount_ = 1 ether; + uint256 maxAmount_ = 10 ether; + uint256 amountPerSecond_ = 1 ether; + uint256 startTime_ = block.timestamp; + bytes memory terms_ = encodeTerms(address(basicERC20), initialAmount_, maxAmount_, amountPerSecond_, startTime_); + + // Transfer 0.5 ether, which is below the allowance so it should succeed. + uint256 transferAmount_ = 0.5 ether; + bytes memory callData_ = encodeERC20Transfer(bob, transferAmount_); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectEmit(true, true, true, true, address(streamingERC20Enforcer)); + emit StreamingERC20Enforcer.IncreasedSpentMap( + address(this), // sender = this test contract is calling beforeHook() + alice, // redeemer = alice is the original message sender in this scenario + bytes32(0), // example delegationHash (we're using 0 here) + address(basicERC20), // token + initialAmount_, + maxAmount_, + amountPerSecond_, + startTime_, + transferAmount_, // spent amount after this transfer + block.timestamp // lastUpdateTimestamp (the event uses current block timestamp) + ); + + streamingERC20Enforcer.beforeHook( + terms_, + bytes(""), // no additional data + mode, // single execution mode + execData_, + bytes32(0), // example delegation hash + address(0), // extra param (unused here) + alice // redeemer + ); + + // Verify final storage + (uint256 storedInitial_, uint256 storedMax, uint256 storedRate_, uint256 storedStart_, uint256 storedSpent_) = + streamingERC20Enforcer.streamingAllowances(address(this), bytes32(0)); + + assertEq(storedInitial_, initialAmount_, "Should store the correct initialAmount"); + assertEq(storedMax, maxAmount_, "Should store correct max"); + assertEq(storedRate_, amountPerSecond_, "Should store the correct amountPerSecond"); + assertEq(storedStart_, startTime_, "Should store the correct startTime"); + assertEq(storedSpent_, transferAmount_, "Should record the correct spent"); + } + + ////////////////////// Valid cases ////////////////////// + + /// @notice Tests that no tokens are available before the configured start time. + function test_getAvailableAmountBeforeStartTime() public { + // This start time is in the future + uint256 futureStart_ = block.timestamp + 1000; + bytes memory terms_ = encodeTerms(address(basicERC20), 50 ether, 100 ether, 1 ether, futureStart_); + + // Prepare a valid IERC20.transfer call + bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + + // Calls beforeHook expecting no tokens to be spendable => must revert + vm.expectRevert("StreamingERC20Enforcer:allowance-exceeded"); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + // Checking getAvailableAmount directly also returns 0 + uint256 available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); + assertEq(available_, 0, "Expected 0 tokens available before start time"); + } + + /** + * @notice Demonstrates a linear streaming scenario (initial=0, max>0, rate>0). + */ + function test_linearStreamingHappyPath() public { + // initial=0 => purely linear, max=5, rate=1, start=now + bytes memory terms_ = encodeTerms(address(basicERC20), 0, 5 ether, 1 ether, block.timestamp); + + // Warp forward 3 seconds => 3 unlocked, but clamp at max=5 + vm.warp(block.timestamp + 3); + + // Transfer 2 => should succeed + bytes memory callData_ = encodeERC20Transfer(bob, 2 ether); + bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + // 3 were available, 2 spent => 1 remains + uint256 available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); + assertEq(available_, 1 ether, "1 ether left after spending 2 of 3"); + + // Warp forward 10 seconds => total unlocked=13, but clamp by max=5 => totalUnlocked=5 + // Spent=2 => 3 remain + vm.warp(block.timestamp + 10); + available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); + assertEq(available_, 3 ether, "Clamped at 5 total unlocked, 2 spent => 3 remain"); + + // Transfer 3 => should succeed + callData_ = encodeERC20Transfer(bob, 3 ether); + execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); + streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + // No available amount + available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); + assertEq(available_, 0, "Available amount should be 0"); + } + + /** + * @notice Demonstrates chunk streaming scenario (initial>0) with partial spending + * and hitting maxAmount clamp. + */ + function test_chunkStreamingHitsMaxAmount() public { + // initial=10, max=25, rate=5 => chunkDuration=10/5=2 seconds + // 1st chunk=10 at start, 2nd chunk after 2 sec, 3rd chunk after 4 sec, etc. + bytes memory terms = encodeTerms(address(basicERC20), 10 ether, 25 ether, 5 ether, block.timestamp); + + // Transfer 10 right away => chunk #1 + bytes memory callData1_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData1_ = encodeSingleExecution(address(basicERC20), 0, callData1_); + streamingERC20Enforcer.beforeHook(terms, bytes(""), mode, execData1_, bytes32(0), address(0), alice); + + // spent=10 => 0 remain from first chunk + // Warp 2 sec => chunk #2 => totalUnlocked=20 => spent=10 => 10 remain + vm.warp(block.timestamp + 2); + uint256 availNow_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); + assertEq(availNow_, 10 ether, "Second chunk unlocked => total=20, spent=10 => 10 remain"); + + // Transfer 10 => spent=20 => 0 remain + bytes memory callData2_ = encodeERC20Transfer(bob, 10 ether); + bytes memory execData2_ = encodeSingleExecution(address(basicERC20), 0, callData2_); + streamingERC20Enforcer.beforeHook(terms, bytes(""), mode, execData2_, bytes32(0), address(0), alice); + + // Warp 2 more sec => chunk #3 => totalUnlocked=30 => clamp to max=25 => spent=20 => 5 remain + vm.warp(block.timestamp + 2); + uint256 availClamped_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); + assertEq(availClamped_, 5 ether, "Clamped at max=25, spent=20 => 5 left"); + } + + ////////////////////// Helper fucntions ////////////////////// + + /** + * @notice Builds a 148-byte `_terms` data for the new streaming logic: + * [0..20] = token address + * [20..52] = initial amount + * [52..84] = max amount + * [84..116] = amount per second + * [116..148]= start time + */ + function encodeTerms( + address token, + uint256 initialAmount, + uint256 maxAmount, + uint256 amountPerSecond, + uint256 startTime + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked( + bytes20(token), bytes32(initialAmount), bytes32(maxAmount), bytes32(amountPerSecond), bytes32(startTime) + ); + } + + /** + * @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); + } + + function encodeSingleExecution(address target, uint256 value, bytes memory callData_) internal pure returns (bytes memory) { + return abi.encodePacked(target, value, callData_); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(streamingERC20Enforcer)); + } +} From 63af391b772f95f568d3106cfecd07bb26802f32 Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Mon, 17 Feb 2025 15:50:26 -0600 Subject: [PATCH 2/4] Implemented linear erc20 streaming, added integration tests, changed name --- script/DeployCaveatEnforcers.s.sol | 6 +- ...nforcer.sol => ERC20StreamingEnforcer.sol} | 130 ++--- test/enforcers/ERC20StreamingEnforcer.t.sol | 543 ++++++++++++++++++ test/enforcers/StreamingERC20Enforcer.t.sol | 396 ------------- 4 files changed, 584 insertions(+), 491 deletions(-) rename src/enforcers/{StreamingERC20Enforcer.sol => ERC20StreamingEnforcer.sol} (56%) create mode 100644 test/enforcers/ERC20StreamingEnforcer.t.sol delete mode 100644 test/enforcers/StreamingERC20Enforcer.t.sol diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index 98c09159..af0d3cad 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -25,7 +25,7 @@ import { NativeTokenTransferAmountEnforcer } from "../src/enforcers/NativeTokenT import { NonceEnforcer } from "../src/enforcers/NonceEnforcer.sol"; import { OwnershipTransferEnforcer } from "../src/enforcers/OwnershipTransferEnforcer.sol"; import { RedeemerEnforcer } from "../src/enforcers/RedeemerEnforcer.sol"; -import { StreamingERC20Enforcer } from "../src/enforcers/StreamingERC20Enforcer.sol"; +import { ERC20StreamingEnforcer } from "../src/enforcers/ERC20StreamingEnforcer.sol"; import { TimestampEnforcer } from "../src/enforcers/TimestampEnforcer.sol"; import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; @@ -117,8 +117,8 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new RedeemerEnforcer{ salt: salt }()); console2.log("RedeemerEnforcer: %s", deployedAddress); - deployedAddress = address(new StreamingERC20Enforcer{ salt: salt }()); - console2.log("StreamingERC20Enforcer: %s", deployedAddress); + deployedAddress = address(new ERC20StreamingEnforcer{ salt: salt }()); + console2.log("ERC20StreamingEnforcer: %s", deployedAddress); deployedAddress = address(new TimestampEnforcer{ salt: salt }()); console2.log("TimestampEnforcer: %s", deployedAddress); diff --git a/src/enforcers/StreamingERC20Enforcer.sol b/src/enforcers/ERC20StreamingEnforcer.sol similarity index 56% rename from src/enforcers/StreamingERC20Enforcer.sol rename to src/enforcers/ERC20StreamingEnforcer.sol index f508f2d5..4a9a86f5 100644 --- a/src/enforcers/StreamingERC20Enforcer.sol +++ b/src/enforcers/ERC20StreamingEnforcer.sol @@ -8,21 +8,20 @@ import { CaveatEnforcer } from "./CaveatEnforcer.sol"; import { ModeCode } from "../utils/Types.sol"; /** - * @title StreamingERC20Enforcer - * @notice This contract enforces a streaming transfer limit for ERC20 tokens. + * @title ERC20StreamingEnforcer + * @notice This contract enforces a linear streaming transfer limit for ERC20 tokens. * * How it works: - * - `maxAmount` is a hard cap on total tokens that can ever become available. - * - If `initialAmount` == 0, the allowance accumulates linearly from `startTime` - * at a rate of `amountPerSecond`. - * - If `initialAmount` > 0, then the allowance is unlocked in "chunks": - * - The first chunk (size = `initialAmount`) is available immediately at `startTime`. - * - Each subsequent chunk is also `initialAmount` in size, and becomes available - * after each `chunkDuration = (initialAmount / amountPerSecond)` seconds. + * 1. Nothing is available before `startTime`. + * 2. Starting at `startTime`, `initialAmount` becomes immediately available. + * 3. Beyond that, tokens accrue linearly at `amountPerSecond`. + * 4. The total unlocked is capped by `maxAmount`. + * 5. The enforcer tracks how many tokens have already been spent, and will revert + * if an attempted transfer exceeds what remains unlocked. * * @dev This caveat enforcer only works when the execution is in single mode (`ModeCode.Single`). */ -contract StreamingERC20Enforcer is CaveatEnforcer { +contract ERC20StreamingEnforcer is CaveatEnforcer { using ExecutionLib for bytes; ////////////////////////////// State ////////////////////////////// @@ -59,14 +58,14 @@ contract StreamingERC20Enforcer is CaveatEnforcer { /** * @notice Retrieves the current available allowance for a specific delegation. - * @param _delegationHash The hash of the delegation being queried. * @param _delegationManager The address of the delegation manager. + * @param _delegationHash The hash of the delegation being queried. * @return availableAmount_ The number of tokens that are currently spendable * under this streaming allowance (capped by `maxAmount`). */ function getAvailableAmount( - bytes32 _delegationHash, - address _delegationManager + address _delegationManager, + bytes32 _delegationHash ) external view @@ -88,6 +87,7 @@ contract StreamingERC20Enforcer is CaveatEnforcer { * @param _mode The mode of the execution (must be `ModeCode.Single` for this enforcer). * @param _executionCallData The transaction the delegate might try to perform. * @param _delegationHash The hash of the delegation being operated on. + * @param _redeemer The address of the redeemer. */ function beforeHook( bytes calldata _terms, @@ -114,7 +114,7 @@ contract StreamingERC20Enforcer is CaveatEnforcer { * - 32 bytes: amount per second. * - 32 bytes: start time for the streaming allowance. * @return token_ The address of the ERC20 token contract. - * @return initialAmount_ The initial chunk size or 0 if purely linear + * @return initialAmount_ The initial amount available at startTime. * @return maxAmount_ The maximum total unlocked tokens (hard cap) * @return amountPerSecond_ The rate at which the allowance increases per second. * @return startTime_ The timestamp from which the allowance streaming begins. @@ -124,7 +124,7 @@ contract StreamingERC20Enforcer is CaveatEnforcer { pure returns (address token_, uint256 initialAmount_, uint256 maxAmount_, uint256 amountPerSecond_, uint256 startTime_) { - require(_terms.length == 148, "StreamingERC20Enforcer:invalid-terms-length"); + require(_terms.length == 148, "ERC20StreamingEnforcer:invalid-terms-length"); token_ = address(bytes20(_terms[0:20])); initialAmount_ = uint256(bytes32(_terms[20:52])); @@ -136,19 +136,14 @@ contract StreamingERC20Enforcer is CaveatEnforcer { ////////////////////////////// Internal Methods ////////////////////////////// /** - * @notice Enforces the streaming allowance limit and updates `spent`. + * @notice Validates the streaming allowance limit and updates `spent`. * @dev Reverts if the transfer amount exceeds the currently available allowance. * - * @param _terms The encoded streaming terms: ERC20 token, initial amount, amount per second, and start time. - * @param _executionCallData The transaction data specifying the target contract and call data. We expect + * @param _terms The encoded streaming terms: ERC20 token, initial amount, max amount, amount per second, and start time. + * @param _executionCallData The transaction data specifying the target contract and call data. Expect * an `IERC20.transfer(address,uint256)` call here. * @param _delegationHash The hash of the delegation to which this transfer applies. - * @return token_ The token address (extracted from `_terms`). - * @return initialAmount_ The `initialAmount` set for this streaming allowance. - * @return maxAmount_ The maximum amount that can be transferred. - * @return amountPerSecond_ The streaming rate specified in `_terms`. - * @return startTime_ The timestamp after which tokens become available. - * @return spent_ The updated `spent` amount after applying this transfer. + * @param _redeemer The address of the redeemer. */ function _validateAndConsumeAllowance( bytes calldata _terms, @@ -157,28 +152,21 @@ contract StreamingERC20Enforcer is CaveatEnforcer { address _redeemer ) private - returns ( - address token_, - uint256 initialAmount_, - uint256 maxAmount_, - uint256 amountPerSecond_, - uint256 startTime_, - uint256 spent_ - ) { (address target_,, bytes calldata callData_) = _executionCallData.decodeSingle(); - require(callData_.length == 68, "StreamingERC20Enforcer:invalid-execution-length"); + require(callData_.length == 68, "ERC20StreamingEnforcer:invalid-execution-length"); - (token_, initialAmount_, maxAmount_, amountPerSecond_, startTime_) = getTermsInfo(_terms); + (address token_, uint256 initialAmount_, uint256 maxAmount_, uint256 amountPerSecond_, uint256 startTime_) = + getTermsInfo(_terms); - require(maxAmount_ >= initialAmount_, "StreamingERC20Enforcer:invalid-max-amount"); + require(maxAmount_ >= initialAmount_, "ERC20StreamingEnforcer:invalid-max-amount"); - require(startTime_ > 0, "StreamingERC20Enforcer:invalid-zero-start-time"); + require(startTime_ > 0, "ERC20StreamingEnforcer:invalid-zero-start-time"); - require(token_ == target_, "StreamingERC20Enforcer:invalid-contract"); + require(token_ == target_, "ERC20StreamingEnforcer:invalid-contract"); - require(bytes4(callData_[0:4]) == IERC20.transfer.selector, "StreamingERC20Enforcer:invalid-method"); + require(bytes4(callData_[0:4]) == IERC20.transfer.selector, "ERC20StreamingEnforcer:invalid-method"); StreamingAllowance storage allowance = streamingAllowances[msg.sender][_delegationHash]; if (allowance.spent == 0) { @@ -191,10 +179,9 @@ contract StreamingERC20Enforcer is CaveatEnforcer { uint256 transferAmount_ = uint256(bytes32(callData_[36:68])); - require(transferAmount_ <= _getAvailableAmount(allowance), "StreamingERC20Enforcer:allowance-exceeded"); + require(transferAmount_ <= _getAvailableAmount(allowance), "ERC20StreamingEnforcer:allowance-exceeded"); allowance.spent += transferAmount_; - spent_ = allowance.spent; emit IncreasedSpentMap( msg.sender, @@ -205,70 +192,29 @@ contract StreamingERC20Enforcer is CaveatEnforcer { maxAmount_, amountPerSecond_, startTime_, - spent_, + allowance.spent, block.timestamp ); } /** - * @notice Calculates the available allowance for a given StreamingAllowance state. - * @dev Computes the remaining allowance based on elapsed time, initial amount, and spent tokens - * then clamps by `maxAmount`. - * @param allowance The StreamingAllowance struct containing allowance details. + * @notice Calculates how many tokens are currently unlocked in total, then subtracts `spent`, then clamps by `maxAmount`. + * @param _allowance The StreamingAllowance struct containing allowance details. * @return A uint256 representing how many tokens are currently available to spend. */ - function _getAvailableAmount(StreamingAllowance storage allowance) internal view returns (uint256) { - if (block.timestamp < allowance.startTime) return 0; - - uint256 elapsed_ = block.timestamp - allowance.startTime; - - // If `initialAmount` == 0, do purely linear streaming - if (allowance.initialAmount == 0) return _computeLinearAllowance(allowance, elapsed_); + function _getAvailableAmount(StreamingAllowance memory _allowance) private view returns (uint256) { + if (block.timestamp < _allowance.startTime) return 0; - require(allowance.amountPerSecond > 0, "StreamingERC20Enforcer:zero-amount-per-second"); + uint256 elapsed_ = block.timestamp - _allowance.startTime; - // If the user wants chunks, ensure the initial amount is large enough - // that `chunkDuration` won't be zero. - require(allowance.initialAmount >= allowance.amountPerSecond, "StreamingERC20Enforcer:initial-amount-is-too-low"); - - // Calculate how many chunks have fully unlocked - uint256 chunkDuration_ = allowance.initialAmount / allowance.amountPerSecond; - uint256 chunksUnlocked_ = elapsed_ / chunkDuration_; - - // The first chunk is unlocked immediately at `startTime`, - uint256 totalUnlocked_ = (chunksUnlocked_ + 1) * allowance.initialAmount; - - // clamp by maxAmount - if (totalUnlocked_ > allowance.maxAmount) { - totalUnlocked_ = allowance.maxAmount; - } - - if (allowance.spent >= totalUnlocked_) return 0; - - return totalUnlocked_ - allowance.spent; - } - - /** - * @notice Computes the unlocked amount using a purely linear model: - * `initialAmount + (amountPerSecond * elapsed)`, then clamps by `maxAmount`. - * - * @dev This function is called when `initialAmount == 0`, or as a fallback - * if chunk-based logic is not feasible. - * - * @param allowance The StreamingAllowance containing the streaming parameters. - * @param elapsed_ How many seconds have passed since `startTime`. - * @return The number of tokens currently available after subtracting `spent`. - */ - function _computeLinearAllowance(StreamingAllowance storage allowance, uint256 elapsed_) private view returns (uint256) { - uint256 totalSoFar_ = allowance.initialAmount + (allowance.amountPerSecond * elapsed_); + uint256 unlocked_ = _allowance.initialAmount + (_allowance.amountPerSecond * elapsed_); - // clamp to maxAmount - if (totalSoFar_ > allowance.maxAmount) { - totalSoFar_ = allowance.maxAmount; + if (unlocked_ > _allowance.maxAmount) { + unlocked_ = _allowance.maxAmount; } - if (allowance.spent >= totalSoFar_) return 0; + if (_allowance.spent >= unlocked_) return 0; - return totalSoFar_ - allowance.spent; + return unlocked_ - _allowance.spent; } } diff --git a/test/enforcers/ERC20StreamingEnforcer.t.sol b/test/enforcers/ERC20StreamingEnforcer.t.sol new file mode 100644 index 00000000..73da8009 --- /dev/null +++ b/test/enforcers/ERC20StreamingEnforcer.t.sol @@ -0,0 +1,543 @@ +// 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 { ERC20StreamingEnforcer } from "../../src/enforcers/ERC20StreamingEnforcer.sol"; +import { BasicERC20, IERC20 } from "../utils/BasicERC20.t.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; + +contract ERC20StreamingEnforcerTest is CaveatEnforcerBaseTest { + using ModeLib for ModeCode; + + ////////////////////////////// State ////////////////////////////// + ERC20StreamingEnforcer public erc20StreamingEnforcer; + BasicERC20 public basicERC20; + ModeCode public mode = ModeLib.encodeSimpleSingle(); + address public alice; + address public bob; + address public carol; + bytes32 public delegationHash; + + ////////////////////// Set up ////////////////////// + + function setUp() public override { + super.setUp(); + erc20StreamingEnforcer = new ERC20StreamingEnforcer(); + vm.label(address(erc20StreamingEnforcer), "Streaming ERC20 Enforcer"); + + alice = address(users.alice.deleGator); + bob = address(users.bob.deleGator); + carol = address(users.carol.deleGator); + + basicERC20 = new BasicERC20(alice, "TestToken", "TestToken", 100 ether); + } + + //////////////////// Error / Revert Tests ////////////////////// + + /** + * @notice Ensures it reverts if `_terms.length != 148`. + */ + function test_invalidTermsLength() public { + // Provide less than 148 bytes + bytes memory badTerms_ = new bytes(100); + + // Minimal callData_ + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:invalid-terms-length")); + erc20StreamingEnforcer.beforeHook(badTerms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Checks revert if `maxAmount < initialAmount`. + */ + function test_invalidMaxAmount() public { + // initial=100, max=50 => revert + bytes memory terms_ = _encodeTerms( + address(basicERC20), + 100 ether, // initial + 50 ether, // max < initial + 1 ether, + block.timestamp + 10 + ); + + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:invalid-max-amount")); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Test that it reverts if startTime == 0. + */ + function test_invalidZeroStartTime() public { + // Prepare valid token and amounts, but zero start time + uint256 startTime_ = 0; + bytes memory terms_ = _encodeTerms(address(basicERC20), 10 ether, 100 ether, 1 ether, startTime_); + + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:invalid-zero-start-time")); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Test that it reverts with `ERC20StreamingEnforcer:allowance-exceeded` + * if the transfer request exceeds the currently unlocked amount. + */ + function test_allowanceExceeded() public { + // Start in the future => 0 available now + uint256 futureStart_ = block.timestamp + 100; + bytes memory terms_ = _encodeTerms(address(basicERC20), 10 ether, 50 ether, 1 ether, futureStart_); + + // Attempt to transfer 10 while 0 is unlocked + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:allowance-exceeded")); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /// @notice Test that it reverts with `ERC20StreamingEnforcer:invalid-execution-length` if the callData_ is not 68 bytes. + function test_invalidExecutionLength() public { + // valid `_terms` + bytes memory terms_ = _encodeTerms(address(basicERC20), 100 ether, 1 ether, 1 ether, block.timestamp + 10); + // Provide some random data that is not exactly 68 bytes + bytes memory badCallData_ = new bytes(40); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, badCallData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:invalid-execution-length")); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Test that it reverts with `ERC20StreamingEnforcer:invalid-method` + * if the selector isn't `IERC20.transfer.selector`. + */ + function test_invalidMethodSelector() public { + bytes memory terms_ = _encodeTerms(address(basicERC20), 100 ether, 100 ether, 1 ether, block.timestamp + 10); + + // Use `transferFrom` instead of `transfer` + bytes memory badCallData_ = abi.encodeWithSelector(IERC20.transferFrom.selector, bob, 10 ether); + + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, badCallData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:invalid-method")); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /// @notice Test that it reverts with `ERC20StreamingEnforcer:invalid-contract` if the token address doesn't match the target. + function test_invalidContract() public { + // Terms says the token is `basicERC20`, but we call a different target in `execData_` + bytes memory terms_ = _encodeTerms(address(basicERC20), 100 ether, 100 ether, 1 ether, block.timestamp + 10); + + // Encode callData_ with correct selector but to a different contract address + BasicERC20 otherToken_ = new BasicERC20(alice, "TestToken2", "TestToken2", 100 ether); + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = _encodeSingleExecution(address(otherToken_), 0, callData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:invalid-contract")); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + //////////////////// Valid cases ////////////////////// + + /** + * @notice Test getTermsInfo() on correct 148-byte terms + */ + function test_getTermsInfoHappyPath() public { + address token_ = address(basicERC20); + uint256 initialAmount_ = 100 ether; + uint256 maxAmount_ = 200 ether; + uint256 amountPerSecond_ = 1 ether; + uint256 startTime_ = block.timestamp + 100; + + bytes memory termsData_ = _encodeTerms(token_, initialAmount_, maxAmount_, amountPerSecond_, startTime_); + + ( + address decodedToken_, + uint256 decodedInitialAmount_, + uint256 decodedMaxAmount_, + uint256 decodedAmountPerSecond_, + uint256 decodedStartTime_ + ) = erc20StreamingEnforcer.getTermsInfo(termsData_); + + assertEq(decodedToken_, token_, "Token mismatch"); + assertEq(decodedInitialAmount_, initialAmount_, "Initial amount mismatch"); + assertEq(decodedMaxAmount_, maxAmount_, "Max amount mismatch"); + assertEq(decodedAmountPerSecond_, amountPerSecond_, "Amount per second mismatch"); + assertEq(decodedStartTime_, startTime_, "Start time mismatch"); + } + + /// @notice Test that getTermsInfo() reverts with `ERC20StreamingEnforcer:invalid-terms-length` if `_terms` is not 148 bytes. + function test_getTermsInfoInvalidLength() public { + // Create terms shorter than 148 bytes + bytes memory shortTermsData_ = new bytes(100); + vm.expectRevert(bytes("ERC20StreamingEnforcer:invalid-terms-length")); + erc20StreamingEnforcer.getTermsInfo(shortTermsData_); + } + + /** + * @notice Confirms the `IncreasedSpentMap` event is emitted for a valid transfer. + */ + function test_increasedSpentMapEvent() public { + uint256 initialAmount_ = 1 ether; + uint256 maxAmount_ = 10 ether; + uint256 amountPerSecond_ = 1 ether; + uint256 startTime_ = block.timestamp; + bytes memory terms_ = _encodeTerms(address(basicERC20), initialAmount_, maxAmount_, amountPerSecond_, startTime_); + + // Transfer 0.5 ether, which is below the allowance so it should succeed. + uint256 transferAmount_ = 0.5 ether; + bytes memory callData_ = _encodeERC20Transfer(bob, transferAmount_); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectEmit(true, true, true, true, address(erc20StreamingEnforcer)); + emit ERC20StreamingEnforcer.IncreasedSpentMap( + address(this), // sender = this test contract is calling beforeHook() + alice, // redeemer = alice is the original message sender in this scenario + bytes32(0), // example delegationHash (we're using 0 here) + address(basicERC20), // token + initialAmount_, + maxAmount_, + amountPerSecond_, + startTime_, + transferAmount_, // spent amount after this transfer + block.timestamp // lastUpdateTimestamp (the event uses current block timestamp) + ); + + erc20StreamingEnforcer.beforeHook( + terms_, + bytes(""), // no additional data + mode, // single execution mode + execData_, + bytes32(0), // example delegation hash + address(0), // extra param (unused here) + alice // redeemer + ); + + // Verify final storage + (uint256 storedInitial_, uint256 storedMax, uint256 storedRate_, uint256 storedStart_, uint256 storedSpent_) = + erc20StreamingEnforcer.streamingAllowances(address(this), bytes32(0)); + + assertEq(storedInitial_, initialAmount_, "Should store the correct initialAmount"); + assertEq(storedMax, maxAmount_, "Should store correct max"); + assertEq(storedRate_, amountPerSecond_, "Should store the correct amountPerSecond"); + assertEq(storedStart_, startTime_, "Should store the correct startTime"); + assertEq(storedSpent_, transferAmount_, "Should record the correct spent"); + } + + /** + * @notice Tests that no tokens are available before the configured start time. + */ + function test_getAvailableAmountBeforeStartTime() public { + // This start time is in the future + uint256 futureStart_ = block.timestamp + 1000; + bytes memory terms_ = _encodeTerms(address(basicERC20), 50 ether, 100 ether, 1 ether, futureStart_); + + // Prepare a valid IERC20.transfer call + bytes memory callData_ = _encodeERC20Transfer(bob, 10 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + // Calls beforeHook expecting no tokens to be spendable => must revert + vm.expectRevert("ERC20StreamingEnforcer:allowance-exceeded"); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + // Checking getAvailableAmount directly also returns 0 + uint256 available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 0, "Expected 0 tokens available before start time"); + } + + /** + * @notice Demonstrates a scenario with initial=0, purely linear streaming. + */ + function test_linearStreamingWithInitialZero() public { + // initial=0 => nothing at startTime, tokens accrue at rate=1 ether/sec + // up to max=5 + bytes memory terms_ = _encodeTerms( + address(basicERC20), + 0, // initial + 5 ether, // max + 1 ether, // rate + block.timestamp + ); + + uint256 available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 0, "Should have 0 tokens available"); + + // After 3 seconds => 3 unlocked (since initial=0) + vm.warp(block.timestamp + 3); + + // Transfer 2 => ok + bytes memory callData_ = _encodeERC20Transfer(bob, 2 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + // 3 were unlocked, spent=2 => 1 left + available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 1 ether, "Should have 1 ether left after spending 2 of 3"); + + // Another 10 seconds => total unlocked=3+10=13, but clamp at max=5 => total=5 => spent=2 => 3 left + vm.warp(block.timestamp + 10); + available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 3 ether, "Should clamp at max=5, spent=2 => 3 remain"); + } + + /** + * @notice Demonstrates a scenario with initial>0 plus linear streaming, + * verifying partial spends and the max clamp. + */ + function test_linearStreamingWithInitialNonzero() public { + // initial=10 => available at startTime, rate=2 => 2 tokens added each second, up to max=20 + uint256 startTime_ = block.timestamp; + bytes memory terms_ = _encodeTerms(address(basicERC20), 10 ether, 20 ether, 2 ether, startTime_); + + // Transfer 5 immediately => 5 left (spent=5) + bytes memory callData_ = _encodeERC20Transfer(bob, 5 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + // spent=5, unlocked=10 => 5 remain + uint256 available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 5 ether, "Should have 5 left from the initial chunk after spending 5"); + + // warp 5 seconds => totalUnlocked=10 + (2*5)=20 => at or beyond max=20 => clamp=20 => spent=5 => 15 left + vm.warp(block.timestamp + 5); + available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 15 ether, "Should have 15 left after 5 seconds of linear accrual, clamped at 20"); + + // Transfer 15 => total spent=20 => 0 remain + callData_ = _encodeERC20Transfer(bob, 15 ether); + execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 0, "Should have 0 left after spending 20 total"); + } + + /** + * @notice Ensures that once the streaming allowance is fully consumed (spent == maxAmount), + * any further transfer attempt reverts with `allowance-exceeded`. + */ + function test_fullySpentCannotTransferMore() public { + // initial=5 => immediately available + // plus linear accrual => rate=2 => but max=5 => we can never exceed 5 total unlocked + // so effectively it's all unlocked at startTime, because initial=5 already hits the max + uint256 startTime_ = block.timestamp; + bytes memory terms_ = _encodeTerms(address(basicERC20), 5 ether, 5 ether, 2 ether, startTime_); + + // Transfer the full 5 => should succeed + bytes memory callData_ = _encodeERC20Transfer(bob, 5 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + // Now spent == maxAmount (5). No more tokens remain. + // Another attempt to transfer any positive amount should revert + callData_ = _encodeERC20Transfer(bob, 1 ether); + execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + vm.expectRevert(bytes("ERC20StreamingEnforcer:allowance-exceeded")); + erc20StreamingEnforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); + } + + /** + * @notice Tests that exactly initialAmount is available at startTime. + */ + function test_availableAtExactStartTime() public { + uint256 startTime_ = block.timestamp + 10; + // initial=8, max=50, rate=2 => at startTime + bytes memory terms = _encodeTerms(address(basicERC20), 8 ether, 50 ether, 2 ether, startTime_); + vm.warp(startTime_); + + // Transfer the full 8 => should succeed + bytes memory callData_ = _encodeERC20Transfer(bob, 8 ether); + bytes memory execData_ = _encodeSingleExecution(address(basicERC20), 0, callData_); + + erc20StreamingEnforcer.beforeHook(terms, bytes(""), mode, execData_, bytes32(0), address(0), alice); + + uint256 available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 0, "After transferring the initial amount 8 ether, 0 should remain at start date"); + + // 5 seconds after start time, it should have accruied 10 ether + vm.warp(block.timestamp + 5); + available_ = erc20StreamingEnforcer.getAvailableAmount(address(this), bytes32(0)); + assertEq(available_, 10 ether, "After 10 seconds, 10 ether should be available"); + } + + ////////////////////// Integration ////////////////////// + + /** + * @notice Integration test: Successful native token streaming via delegation. + * A delegation is created that uses the erc20StreamingEnforcer. Two native token transfers + * (user ops) are executed sequentially. The test verifies that the enforcer’s state is updated + * correctly and that the available amount decreases as expected. + */ + function test_nativeTokenStreamingIntegration_Success() public { + // Prepare the streaming terms: + // initial = 5 ether (available immediately at startTime), + // max = 20 ether (the cap), + // rate = 2 ether per second, + // startTime = current block timestamp. + uint256 startTime = block.timestamp; + bytes memory terms = _encodeTerms(address(basicERC20), 5 ether, 20 ether, 2 ether, startTime); + + // Create a caveat that uses the native token streaming enforcer. + Caveat[] memory caveats = new Caveat[](1); + caveats[0] = Caveat({ args: hex"", enforcer: address(erc20StreamingEnforcer), terms: terms }); + + // Build a delegation using the caveats array. + Delegation memory delegation = + Delegation({ delegate: bob, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats, salt: 0, signature: hex"" }); + delegation = signDelegation(users.alice, delegation); + delegationHash = EncoderLib._getDelegationHash(delegation); + + Delegation[] memory delegations = new Delegation[](1); + delegations[0] = delegation; + + uint256 balanceCarol = basicERC20.balanceOf(carol); + + // --- First UserOp: Transfer 3 native tokens --- + // Create an execution that represents a native token transfer of 3 ether to Carol + bytes memory callData_ = _encodeERC20Transfer(carol, 3 ether); + Execution memory execution1 = Execution({ target: address(basicERC20), value: 0, callData: callData_ }); + + // Invoke the delegation user op. + invokeDelegation_UserOp(users.bob, delegations, execution1); + + balanceCarol += 3 ether; + assertEq(basicERC20.balanceOf(carol), balanceCarol, "Carol should have received 3 ether"); + + // At this point, the enforcer should have recorded 3 ether as spent. + (uint256 storedInitial, uint256 storedMax, uint256 storedRate, uint256 storedStart, uint256 storedSpent) = + erc20StreamingEnforcer.streamingAllowances(address(delegationManager), delegationHash); + assertEq(storedInitial, 5 ether, "Initial amount should be 5 ether"); + assertEq(storedMax, 20 ether, "Max amount should be 20 ether"); + assertEq(storedRate, 2 ether, "Stored rate should be 2 ether"); + assertEq(storedStart, startTime, "Stored start should be startTime"); + assertEq(storedSpent, 3 ether, "Spent should be 3 ether after first op"); + + // The unlocked amount at startTime is initial (5 ether), so available should be 5-3 = 2 ether. + uint256 availableAfter1 = erc20StreamingEnforcer.getAvailableAmount(address(delegationManager), delegationHash); + assertEq(availableAfter1, 2 ether, "Available should be 2 ether after first op"); + + // --- Second UserOp: Transfer 4 native tokens after time warp --- + // Warp forward 5 seconds. Now unlocked = 5 + (2 * 5) = 15 ether, cap is 20. + vm.warp(block.timestamp + 5); + + // Create an execution for transferring 4 ether. + callData_ = _encodeERC20Transfer(carol, 4 ether); + Execution memory execution2 = Execution({ target: address(basicERC20), value: 0, callData: callData_ }); + + // Invoke the user op. + invokeDelegation_UserOp(users.bob, delegations, execution2); + + balanceCarol += 4 ether; + assertEq(basicERC20.balanceOf(carol), balanceCarol, "Carol should have received 4 ether"); + + // Total spent should now be 3 + 4 = 7 ether. + (,,,, uint256 spentAfter2) = erc20StreamingEnforcer.streamingAllowances(address(delegationManager), delegationHash); + assertEq(spentAfter2, 7 ether, "Spent should be 7 ether after second op"); + + // Available should now be unlocked (15) - spent (7) = 8 ether. + uint256 availableAfter2 = erc20StreamingEnforcer.getAvailableAmount(address(delegationManager), delegationHash); + assertEq(availableAfter2, 8 ether, "Available should be 8 ether after second op"); + } + + /** + * @notice Integration test: Failing native token streaming due to exceeding allowance. + * A delegation is created with streaming terms where the maximum equals the initial amount. + * After consuming the full allowance, a subsequent native token transfer attempt should revert. + */ + function test_nativeTokenStreamingIntegration_ExceedsAllowance() public { + // Set streaming terms: + // initial = 5 ether, max = 5 ether (so no accrual beyond startTime), rate = 1 ether/sec. + uint256 startTime = block.timestamp; + bytes memory terms = _encodeTerms(address(basicERC20), 5 ether, 5 ether, 1 ether, startTime); + + // Create caveats and delegation + Caveat[] memory caveats = new Caveat[](1); + caveats[0] = Caveat({ args: hex"", enforcer: address(erc20StreamingEnforcer), terms: terms }); + Delegation memory delegation = + Delegation({ delegate: bob, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats, salt: 0, signature: hex"" }); + delegation = signDelegation(users.alice, delegation); + delegationHash = EncoderLib._getDelegationHash(delegation); + + Delegation[] memory delegations = new Delegation[](1); + delegations[0] = delegation; + + uint256 balanceCarol = basicERC20.balanceOf(carol); + + // First, invoke a user op to transfer the full 5 ether. + bytes memory callData_ = _encodeERC20Transfer(carol, 5 ether); + Execution memory execution1 = Execution({ target: address(basicERC20), value: 0, callData: callData_ }); + invokeDelegation_UserOp(users.bob, delegations, execution1); + + balanceCarol += 5 ether; + assertEq(basicERC20.balanceOf(carol), balanceCarol, "Carol should have received 5 ether"); + + // Now the allowance is fully consumed (spent == max = 5 ether). Available = 0. + uint256 available = erc20StreamingEnforcer.getAvailableAmount(address(delegationManager), delegationHash); + assertEq(available, 0, "Available should be 0 after full consumption"); + + // Next, attempt another native token transfer of 1 ether. + callData_ = _encodeERC20Transfer(carol, 1 ether); + Execution memory execution2 = Execution({ target: address(basicERC20), value: 0, callData: callData_ }); + // vm.expectRevert(bytes("erc20StreamingEnforcer:allowance-exceeded")); + invokeDelegation_UserOp(users.bob, delegations, execution2); + + assertEq(basicERC20.balanceOf(carol), balanceCarol, "Carol should not have received anything"); + } + + ////////////////////// Helper fucntions ////////////////////// + + /** + * @notice Builds a 148-byte `_terms` data for the new streaming logic: + * [0..20] = token address + * [20..52] = initial amount + * [52..84] = max amount + * [84..116] = amount per second + * [116..148]= start time + */ + function _encodeTerms( + address _token, + uint256 _initialAmount, + uint256 _maxAmount, + uint256 _amountPerSecond, + uint256 _startTime + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked( + bytes20(_token), bytes32(_initialAmount), bytes32(_maxAmount), bytes32(_amountPerSecond), bytes32(_startTime) + ); + } + + /** + * @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); + } + + function _encodeSingleExecution(address _target, uint256 _value, bytes memory _callData) internal pure returns (bytes memory) { + return abi.encodePacked(_target, _value, _callData); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(erc20StreamingEnforcer)); + } +} diff --git a/test/enforcers/StreamingERC20Enforcer.t.sol b/test/enforcers/StreamingERC20Enforcer.t.sol deleted file mode 100644 index 0eb112b1..00000000 --- a/test/enforcers/StreamingERC20Enforcer.t.sol +++ /dev/null @@ -1,396 +0,0 @@ -// 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 } from "../../src/utils/Types.sol"; -import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; -import { StreamingERC20Enforcer } from "../../src/enforcers/StreamingERC20Enforcer.sol"; -import { BasicERC20, IERC20 } from "../utils/BasicERC20.t.sol"; -import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; - -contract StreamingERC20EnforcerTest is CaveatEnforcerBaseTest { - using ModeLib for ModeCode; - - ////////////////////////////// State ////////////////////////////// - StreamingERC20Enforcer public streamingERC20Enforcer; - BasicERC20 public basicERC20; - ModeCode public mode = ModeLib.encodeSimpleSingle(); - address public alice; - address public bob; - - ////////////////////// Set up ////////////////////// - - function setUp() public override { - super.setUp(); - streamingERC20Enforcer = new StreamingERC20Enforcer(); - vm.label(address(streamingERC20Enforcer), "Streaming ERC20 Enforcer"); - - alice = address(users.alice.deleGator); - bob = address(users.bob.deleGator); - - basicERC20 = new BasicERC20(alice, "TestToken", "TestToken", 100 ether); - } - - //////////////////// Error / Revert Tests ////////////////////// - - /** - * @notice Ensures it reverts if `_terms.length != 148`. - */ - function test_invalidTermsLength() public { - // Provide fewer than 148 bytes - bytes memory badTerms = new bytes(100); - - // Minimal callData_ - bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-terms-length")); - streamingERC20Enforcer.beforeHook(badTerms, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /** - * @notice Checks revert if `maxAmount < initialAmount`. - */ - function test_invalidMaxAmount() public { - // initial=100, max=50 => revert - bytes memory terms = encodeTerms( - address(basicERC20), - 100 ether, // initial - 50 ether, // max < initial - 1 ether, - block.timestamp + 10 - ); - - bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-max-amount")); - streamingERC20Enforcer.beforeHook(terms, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /** - * @notice Test that it reverts if startTime == 0. - */ - function test_invalidZeroStartTime() public { - // Prepare valid token and amounts, but zero start time - uint256 startTime_ = 0; - bytes memory terms_ = encodeTerms(address(basicERC20), 10 ether, 100 ether, 1 ether, startTime_); - - bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-zero-start-time")); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /** - * @notice Test that it reverts with `StreamingERC20Enforcer:allowance-exceeded` - * if the transfer request exceeds the currently unlocked amount. - */ - function test_allowanceExceeded() public { - // Start in the future => 0 available now - uint256 start_ = block.timestamp + 100; - bytes memory terms_ = encodeTerms(address(basicERC20), 10 ether, 50 ether, 1 ether, start_); - - // Trying to transfer more than is available (which is 0 if we call now). - bytes memory callData_ = encodeERC20Transfer(bob, 50 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:allowance-exceeded")); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /** - * @notice Test chunk logic revert if `initialAmount` > 0 but `amountPerSecond=0`. - */ - function test_zeroAmountPerSecondChunkLogic() public { - bytes memory terms_ = encodeTerms( - address(basicERC20), - 100 ether, // initial - 500 ether, // max - 0, // amountPerSecond=0 - block.timestamp - ); - - // The call data is valid - bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - // Because initialAmount > 0 and amountPerSecond = 0, chunk logic triggers the revert - vm.warp(block.timestamp + 1); - vm.expectRevert(bytes("StreamingERC20Enforcer:zero-amount-per-second")); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /// @notice Test chunk logic revert if initialAmount < amountPerSecond. - function test_initialAmountTooLow() public { - // initial=1, rate=2 => revert - bytes memory terms_ = encodeTerms(address(basicERC20), 1 ether, 10 ether, 2 ether, block.timestamp); - - bytes memory callData_ = encodeERC20Transfer(bob, 1 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:initial-amount-is-too-low")); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /// @notice Test that it reverts with `StreamingERC20Enforcer:invalid-execution-length` if the callData_ is not 68 bytes. - function test_invalidExecutionLength() public { - // valid `_terms` - bytes memory terms_ = encodeTerms(address(basicERC20), 100 ether, 1 ether, 1 ether, block.timestamp + 10); - // Provide some random data that is not exactly 68 bytes - bytes memory callData_ = new bytes(40); - // encodeSingleExecution - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-execution-length")); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /// @notice Test that it reverts with `StreamingERC20Enforcer:invalid-method` if the selector isn't `transfer`. - function test_invalidMethodSelector() public { - bytes memory terms_ = encodeTerms(address(basicERC20), 100 ether, 100 ether, 1 ether, block.timestamp + 10); - - // Calling transferFrom() method instead of the valid transfer method - bytes memory badCallData_ = abi.encodeWithSelector(IERC20.transferFrom.selector, bob, 10 ether); - - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, badCallData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-method")); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - /// @notice Test that it reverts with `StreamingERC20Enforcer:invalid-contract` if the token address doesn't match the target. - function test_invalidContract() public { - // Terms says the token is `basicERC20`, but we call a different target in `execData_` - bytes memory terms_ = encodeTerms(address(basicERC20), 100 ether, 100 ether, 1 ether, block.timestamp + 10); - - // Encode callData_ with correct selector but to a different contract address - BasicERC20 otherToken_ = new BasicERC20(alice, "TestToken2", "TestToken2", 100 ether); - bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData_ = encodeSingleExecution(address(otherToken_), 0, callData_); - - vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-contract")); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - } - - //////////////////// Valid cases ////////////////////// - - /// @notice Test that getTermsInfo() decodes valid 148-byte terms correctly. - function test_getTermsInfoHappyPath() public { - address token_ = address(basicERC20); - uint256 initialAmount_ = 100 ether; - uint256 maxAmount_ = 50 ether; - uint256 amountPerSecond_ = 1 ether; - uint256 startTime_ = block.timestamp + 100; - - bytes memory termsData_ = encodeTerms(token_, initialAmount_, maxAmount_, amountPerSecond_, startTime_); - - ( - address decodedToken_, - uint256 decodedInitialAmount_, - uint256 decodedMaxAmount_, - uint256 decodedAmountPerSecond_, - uint256 decodedStartTime_ - ) = streamingERC20Enforcer.getTermsInfo(termsData_); - - assertEq(decodedToken_, token_, "Token mismatch"); - assertEq(decodedInitialAmount_, initialAmount_, "Initial amount mismatch"); - assertEq(decodedMaxAmount_, maxAmount_, "Max amount mismatch"); - assertEq(decodedAmountPerSecond_, amountPerSecond_, "Amount per second mismatch"); - assertEq(decodedStartTime_, startTime_, "Start time mismatch"); - } - - /// @notice Test that getTermsInfo() reverts with `StreamingERC20Enforcer:invalid-terms-length` if `_terms` is not 148 bytes. - function test_getTermsInfoInvalidLength() public { - // Create an array shorter than 1 bytes - bytes memory shortTermsData = new bytes(100); - - // Expect the specific revert - vm.expectRevert(bytes("StreamingERC20Enforcer:invalid-terms-length")); - streamingERC20Enforcer.getTermsInfo(shortTermsData); - } - - /** - * @notice Confirms the `IncreasedSpentMap` event is emitted for a valid transfer. - */ - function test_increasedSpentMapEvent() public { - uint256 initialAmount_ = 1 ether; - uint256 maxAmount_ = 10 ether; - uint256 amountPerSecond_ = 1 ether; - uint256 startTime_ = block.timestamp; - bytes memory terms_ = encodeTerms(address(basicERC20), initialAmount_, maxAmount_, amountPerSecond_, startTime_); - - // Transfer 0.5 ether, which is below the allowance so it should succeed. - uint256 transferAmount_ = 0.5 ether; - bytes memory callData_ = encodeERC20Transfer(bob, transferAmount_); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - vm.expectEmit(true, true, true, true, address(streamingERC20Enforcer)); - emit StreamingERC20Enforcer.IncreasedSpentMap( - address(this), // sender = this test contract is calling beforeHook() - alice, // redeemer = alice is the original message sender in this scenario - bytes32(0), // example delegationHash (we're using 0 here) - address(basicERC20), // token - initialAmount_, - maxAmount_, - amountPerSecond_, - startTime_, - transferAmount_, // spent amount after this transfer - block.timestamp // lastUpdateTimestamp (the event uses current block timestamp) - ); - - streamingERC20Enforcer.beforeHook( - terms_, - bytes(""), // no additional data - mode, // single execution mode - execData_, - bytes32(0), // example delegation hash - address(0), // extra param (unused here) - alice // redeemer - ); - - // Verify final storage - (uint256 storedInitial_, uint256 storedMax, uint256 storedRate_, uint256 storedStart_, uint256 storedSpent_) = - streamingERC20Enforcer.streamingAllowances(address(this), bytes32(0)); - - assertEq(storedInitial_, initialAmount_, "Should store the correct initialAmount"); - assertEq(storedMax, maxAmount_, "Should store correct max"); - assertEq(storedRate_, amountPerSecond_, "Should store the correct amountPerSecond"); - assertEq(storedStart_, startTime_, "Should store the correct startTime"); - assertEq(storedSpent_, transferAmount_, "Should record the correct spent"); - } - - ////////////////////// Valid cases ////////////////////// - - /// @notice Tests that no tokens are available before the configured start time. - function test_getAvailableAmountBeforeStartTime() public { - // This start time is in the future - uint256 futureStart_ = block.timestamp + 1000; - bytes memory terms_ = encodeTerms(address(basicERC20), 50 ether, 100 ether, 1 ether, futureStart_); - - // Prepare a valid IERC20.transfer call - bytes memory callData_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - - // Calls beforeHook expecting no tokens to be spendable => must revert - vm.expectRevert("StreamingERC20Enforcer:allowance-exceeded"); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - - // Checking getAvailableAmount directly also returns 0 - uint256 available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); - assertEq(available_, 0, "Expected 0 tokens available before start time"); - } - - /** - * @notice Demonstrates a linear streaming scenario (initial=0, max>0, rate>0). - */ - function test_linearStreamingHappyPath() public { - // initial=0 => purely linear, max=5, rate=1, start=now - bytes memory terms_ = encodeTerms(address(basicERC20), 0, 5 ether, 1 ether, block.timestamp); - - // Warp forward 3 seconds => 3 unlocked, but clamp at max=5 - vm.warp(block.timestamp + 3); - - // Transfer 2 => should succeed - bytes memory callData_ = encodeERC20Transfer(bob, 2 ether); - bytes memory execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - - // 3 were available, 2 spent => 1 remains - uint256 available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); - assertEq(available_, 1 ether, "1 ether left after spending 2 of 3"); - - // Warp forward 10 seconds => total unlocked=13, but clamp by max=5 => totalUnlocked=5 - // Spent=2 => 3 remain - vm.warp(block.timestamp + 10); - available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); - assertEq(available_, 3 ether, "Clamped at 5 total unlocked, 2 spent => 3 remain"); - - // Transfer 3 => should succeed - callData_ = encodeERC20Transfer(bob, 3 ether); - execData_ = encodeSingleExecution(address(basicERC20), 0, callData_); - streamingERC20Enforcer.beforeHook(terms_, bytes(""), mode, execData_, bytes32(0), address(0), alice); - - // No available amount - available_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); - assertEq(available_, 0, "Available amount should be 0"); - } - - /** - * @notice Demonstrates chunk streaming scenario (initial>0) with partial spending - * and hitting maxAmount clamp. - */ - function test_chunkStreamingHitsMaxAmount() public { - // initial=10, max=25, rate=5 => chunkDuration=10/5=2 seconds - // 1st chunk=10 at start, 2nd chunk after 2 sec, 3rd chunk after 4 sec, etc. - bytes memory terms = encodeTerms(address(basicERC20), 10 ether, 25 ether, 5 ether, block.timestamp); - - // Transfer 10 right away => chunk #1 - bytes memory callData1_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData1_ = encodeSingleExecution(address(basicERC20), 0, callData1_); - streamingERC20Enforcer.beforeHook(terms, bytes(""), mode, execData1_, bytes32(0), address(0), alice); - - // spent=10 => 0 remain from first chunk - // Warp 2 sec => chunk #2 => totalUnlocked=20 => spent=10 => 10 remain - vm.warp(block.timestamp + 2); - uint256 availNow_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); - assertEq(availNow_, 10 ether, "Second chunk unlocked => total=20, spent=10 => 10 remain"); - - // Transfer 10 => spent=20 => 0 remain - bytes memory callData2_ = encodeERC20Transfer(bob, 10 ether); - bytes memory execData2_ = encodeSingleExecution(address(basicERC20), 0, callData2_); - streamingERC20Enforcer.beforeHook(terms, bytes(""), mode, execData2_, bytes32(0), address(0), alice); - - // Warp 2 more sec => chunk #3 => totalUnlocked=30 => clamp to max=25 => spent=20 => 5 remain - vm.warp(block.timestamp + 2); - uint256 availClamped_ = streamingERC20Enforcer.getAvailableAmount(bytes32(0), address(this)); - assertEq(availClamped_, 5 ether, "Clamped at max=25, spent=20 => 5 left"); - } - - ////////////////////// Helper fucntions ////////////////////// - - /** - * @notice Builds a 148-byte `_terms` data for the new streaming logic: - * [0..20] = token address - * [20..52] = initial amount - * [52..84] = max amount - * [84..116] = amount per second - * [116..148]= start time - */ - function encodeTerms( - address token, - uint256 initialAmount, - uint256 maxAmount, - uint256 amountPerSecond, - uint256 startTime - ) - internal - pure - returns (bytes memory) - { - return abi.encodePacked( - bytes20(token), bytes32(initialAmount), bytes32(maxAmount), bytes32(amountPerSecond), bytes32(startTime) - ); - } - - /** - * @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); - } - - function encodeSingleExecution(address target, uint256 value, bytes memory callData_) internal pure returns (bytes memory) { - return abi.encodePacked(target, value, callData_); - } - - function _getEnforcer() internal view override returns (ICaveatEnforcer) { - return ICaveatEnforcer(address(streamingERC20Enforcer)); - } -} From 1b81fca57ab64078e9aa28a05358719226ebefd9 Mon Sep 17 00:00:00 2001 From: Hanzel Anchia Mena <33629234+hanzel98@users.noreply.github.com> Date: Thu, 20 Feb 2025 15:38:48 -0600 Subject: [PATCH 3/4] Added clarification about the maxAmount usage Co-authored-by: Ryan <81343914+McOso@users.noreply.github.com> --- src/enforcers/ERC20StreamingEnforcer.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/enforcers/ERC20StreamingEnforcer.sol b/src/enforcers/ERC20StreamingEnforcer.sol index 4a9a86f5..4f4e5c92 100644 --- a/src/enforcers/ERC20StreamingEnforcer.sol +++ b/src/enforcers/ERC20StreamingEnforcer.sol @@ -20,6 +20,7 @@ import { ModeCode } from "../utils/Types.sol"; * if an attempted transfer exceeds what remains unlocked. * * @dev This caveat enforcer only works when the execution is in single mode (`ModeCode.Single`). + * @dev To enable an 'infinite' token stream, set `maxAmount` to type(uint256).max */ contract ERC20StreamingEnforcer is CaveatEnforcer { using ExecutionLib for bytes; From 9bb76fd0d26b95b9b24166b596fa920f8e4f8b3b Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Thu, 20 Feb 2025 19:03:32 -0600 Subject: [PATCH 4/4] Style underscore for function variable --- src/enforcers/ERC20StreamingEnforcer.sol | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/enforcers/ERC20StreamingEnforcer.sol b/src/enforcers/ERC20StreamingEnforcer.sol index 4f4e5c92..bc5fa412 100644 --- a/src/enforcers/ERC20StreamingEnforcer.sol +++ b/src/enforcers/ERC20StreamingEnforcer.sol @@ -72,8 +72,8 @@ contract ERC20StreamingEnforcer is CaveatEnforcer { view returns (uint256 availableAmount_) { - StreamingAllowance storage allowance = streamingAllowances[_delegationManager][_delegationHash]; - availableAmount_ = _getAvailableAmount(allowance); + StreamingAllowance storage allowance_ = streamingAllowances[_delegationManager][_delegationHash]; + availableAmount_ = _getAvailableAmount(allowance_); } /** @@ -169,20 +169,20 @@ contract ERC20StreamingEnforcer is CaveatEnforcer { require(bytes4(callData_[0:4]) == IERC20.transfer.selector, "ERC20StreamingEnforcer:invalid-method"); - StreamingAllowance storage allowance = streamingAllowances[msg.sender][_delegationHash]; - if (allowance.spent == 0) { + StreamingAllowance storage allowance_ = streamingAllowances[msg.sender][_delegationHash]; + if (allowance_.spent == 0) { // First use of this delegation - allowance.initialAmount = initialAmount_; - allowance.maxAmount = maxAmount_; - allowance.amountPerSecond = amountPerSecond_; - allowance.startTime = startTime_; + allowance_.initialAmount = initialAmount_; + allowance_.maxAmount = maxAmount_; + allowance_.amountPerSecond = amountPerSecond_; + allowance_.startTime = startTime_; } uint256 transferAmount_ = uint256(bytes32(callData_[36:68])); - require(transferAmount_ <= _getAvailableAmount(allowance), "ERC20StreamingEnforcer:allowance-exceeded"); + require(transferAmount_ <= _getAvailableAmount(allowance_), "ERC20StreamingEnforcer:allowance-exceeded"); - allowance.spent += transferAmount_; + allowance_.spent += transferAmount_; emit IncreasedSpentMap( msg.sender, @@ -193,7 +193,7 @@ contract ERC20StreamingEnforcer is CaveatEnforcer { maxAmount_, amountPerSecond_, startTime_, - allowance.spent, + allowance_.spent, block.timestamp ); }