Skip to content

Commit

Permalink
Add streaming erc20 enforcer
Browse files Browse the repository at this point in the history
  • Loading branch information
danfinlay committed Sep 30, 2024
1 parent 47a51b4 commit aa8b0a1
Show file tree
Hide file tree
Showing 2 changed files with 315 additions and 0 deletions.
135 changes: 135 additions & 0 deletions src/enforcers/StreamingERC20Enforcer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// 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
* @dev This contract enforces a streaming transfer limit for ERC20 tokens.
* @dev The allowance increases linearly over time from a specified start time.
* @dev This caveat enforcer only works when the execution is in single mode.
*/
contract StreamingERC20Enforcer is CaveatEnforcer {
using ExecutionLib for bytes;

////////////////////////////// State //////////////////////////////

struct StreamingAllowance {
uint256 initialAmount;
uint256 amountPerSecond;
uint256 startTime;
uint256 lastUpdateTimestamp;
uint256 spent;
}

mapping(address delegationManager => mapping(bytes32 delegationHash => StreamingAllowance)) public streamingAllowances;

////////////////////////////// Events //////////////////////////////
event IncreasedSpentMap(
address indexed sender,
address indexed redeemer,
bytes32 indexed delegationHash,
uint256 initialLimit,
uint256 amountPerSecond,
uint256 startTime,
uint256 spent
);

////////////////////////////// Public Methods //////////////////////////////

/**
* @notice Allows the delegator to specify a streaming maximum sum of the contract token to transfer on their behalf.
* @dev This function enforces the streaming transfer limit before the transaction is performed.
* @param _terms The ERC20 token address, initial amount, amount per second, and start time for the streaming allowance.
* @param _mode The mode of the execution.
* @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)
{
(uint256 initialLimit_, uint256 amountPerSecond_, uint256 startTime_, uint256 spent_) = _validateAndIncrease(_terms, _executionCallData, _delegationHash);
emit IncreasedSpentMap(msg.sender, _redeemer, _delegationHash, initialLimit_, amountPerSecond_, startTime_, spent_);
}

/**
* @notice Decodes the terms used in this CaveatEnforcer.
* @param _terms encoded data that is used during the execution hooks.
* @return allowedContract_ The address of the ERC20 token contract.
* @return initialAmount_ The initial amount of tokens that the delegate is allowed to transfer.
* @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 allowedContract_, uint256 initialAmount_, uint256 amountPerSecond_, uint256 startTime_) {
require(_terms.length == 116, "StreamingERC20Enforcer:invalid-terms-length");

allowedContract_ = address((bytes20(_terms[:20])));
initialAmount_ = uint256(bytes32(_terms[20:52]));
amountPerSecond_ = uint256(bytes32(_terms[52:84]));
startTime_ = uint256(bytes32(_terms[84:]));
}

/**
* @notice Returns the current allowance and updates the spent amount.
* @param _terms The ERC20 token address, initial amount, amount per second, and start time.
* @param _executionCallData The transaction the delegate might try to perform.
* @param _delegationHash The hash of the delegation being operated on.
* @return initialLimit_ The initial amount of tokens that the delegator is allowed to spend.
* @return amountPerSecond_ The rate at which the allowance increases per second.
* @return startTime_ The timestamp from which the allowance streaming begins.
* @return spent_ The updated amount of tokens that the delegator has spent.
*/
function _validateAndIncrease(
bytes calldata _terms,
bytes calldata _executionCallData,
bytes32 _delegationHash
)
internal
returns (uint256 initialLimit_, uint256 amountPerSecond_, uint256 startTime_, uint256 spent_)
{
(address target_,, bytes calldata callData_) = _executionCallData.decodeSingle();

require(callData_.length == 68, "StreamingERC20Enforcer:invalid-execution-length");

address allowedContract_;
(allowedContract_, initialLimit_, amountPerSecond_, startTime_) = getTermsInfo(_terms);

require(allowedContract_ == target_, "StreamingERC20Enforcer:invalid-contract");
require(bytes4(callData_[0:4]) == IERC20.transfer.selector, "StreamingERC20Enforcer:invalid-method");

StreamingAllowance storage allowance = streamingAllowances[msg.sender][_delegationHash];

if (allowance.lastUpdateTimestamp == 0) {
// First use of this delegation
allowance.initialAmount = initialLimit_;
allowance.amountPerSecond = amountPerSecond_;
allowance.startTime = startTime_;
allowance.lastUpdateTimestamp = block.timestamp;
allowance.spent = 0;
}

uint256 elapsedTime = block.timestamp > allowance.startTime ? block.timestamp - allowance.startTime : 0;
uint256 currentAllowance = allowance.initialAmount + (allowance.amountPerSecond * elapsedTime);

uint256 transferAmount = uint256(bytes32(callData_[36:68]));
require(allowance.spent + transferAmount <= currentAllowance, "StreamingERC20Enforcer:allowance-exceeded");

allowance.spent += transferAmount;
allowance.lastUpdateTimestamp = block.timestamp;
spent_ = allowance.spent;
}
}
180 changes: 180 additions & 0 deletions test/enforcers/StreamingERC20Enforcer.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// SPDX-License-Identifier: MIT AND Apache-2.0
pragma solidity 0.8.23;

import "forge-std/Test.sol";
import { ModeLib } from "@erc7579/lib/ModeLib.sol";
import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol";

import { Execution, Caveat, Delegation, ModeCode } from "../../src/utils/Types.sol";
import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol";
import { StreamingERC20Enforcer } from "../../src/enforcers/StreamingERC20Enforcer.sol";
import { BasicERC20, IERC20 } from "../utils/BasicERC20.t.sol";
import { EncoderLib } from "../../src/libraries/EncoderLib.sol";
import { IDelegationManager } from "../../src/interfaces/IDelegationManager.sol";
import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol";
import { Caveats } from "../../src/libraries/Caveats.sol";

contract StreamingERC20EnforcerTest is CaveatEnforcerBaseTest {
using ModeLib for ModeCode;

////////////////////////////// State //////////////////////////////
StreamingERC20Enforcer public streamingERC20Enforcer;
BasicERC20 public basicERC20;
BasicERC20 public invalidERC20;
ModeCode public mode = ModeLib.encodeSimpleSingle();

////////////////////////////// Events //////////////////////////////
event IncreasedSpentMap(
address indexed sender,
address indexed redeemer,
bytes32 indexed delegationHash,
uint256 initialLimit,
uint256 amountPerSecond,
uint256 startTime,
uint256 spent
);

////////////////////// Set up //////////////////////

function setUp() public override {
super.setUp();
streamingERC20Enforcer = new StreamingERC20Enforcer();
vm.label(address(streamingERC20Enforcer), "Streaming ERC20 Enforcer");
basicERC20 = new BasicERC20(address(users.alice.deleGator), "TestToken", "TestToken", 100 ether);
invalidERC20 = new BasicERC20(address(users.alice.addr), "InvalidToken", "IT", 100 ether);
}

//////////////////// Valid cases //////////////////////

// should SUCCEED to INVOKE transfer BELOW streaming allowance
function test_transferSucceedsIfCalledBelowAllowance() public {
uint256 initialLimit = 1 ether;
uint256 amountPerSecond = 0.1 ether;
uint256 startTime = block.timestamp;

// Create the execution that would be executed
Execution memory execution_ = Execution({
target: address(basicERC20),
value: 0,
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), 0.5 ether)
});
bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData);

bytes memory inputTerms_ = abi.encodePacked(address(basicERC20), initialLimit, amountPerSecond, startTime);
bytes32 delegationHash_ = keccak256("test");

vm.prank(address(delegationManager));
vm.expectEmit(true, true, true, true, address(streamingERC20Enforcer));
emit IncreasedSpentMap(address(delegationManager), address(0), delegationHash_, initialLimit, amountPerSecond, startTime, 0.5 ether);
streamingERC20Enforcer.beforeHook(
inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(0)
);

(,,,, uint256 spent) = streamingERC20Enforcer.streamingAllowances(address(delegationManager), delegationHash_);
assertEq(spent, 0.5 ether);
}

////////////////////// Invalid cases //////////////////////

// should FAIL to INVOKE transfer ABOVE streaming allowance
function test_transferFailsIfCalledAboveAllowance() public {
uint256 initialLimit = 1 ether;
uint256 amountPerSecond = 0.1 ether;
uint256 startTime = block.timestamp;

// Create the execution that would be executed
Execution memory execution_ = Execution({
target: address(basicERC20),
value: 0,
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), 1.5 ether)
});
bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData);

bytes memory inputTerms_ = abi.encodePacked(address(basicERC20), initialLimit, amountPerSecond, startTime);
bytes32 delegationHash_ = keccak256("test");

vm.prank(address(delegationManager));
vm.expectRevert("StreamingERC20Enforcer:allowance-exceeded");
streamingERC20Enforcer.beforeHook(
inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(0)
);
}

// should FAIL to INVOKE invalid execution data length
function test_notAllow_invalidExecutionLength() public {
uint256 initialLimit = 1 ether;
uint256 amountPerSecond = 0.1 ether;
uint256 startTime = block.timestamp;

// Create the execution that would be executed with invalid length
Execution memory execution_ = Execution({
target: address(basicERC20),
value: 0,
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator))
});
bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData);

bytes memory inputTerms_ = abi.encodePacked(address(basicERC20), initialLimit, amountPerSecond, startTime);
bytes32 delegationHash_ = keccak256("test");

vm.prank(address(delegationManager));
vm.expectRevert("StreamingERC20Enforcer:invalid-execution-length");
streamingERC20Enforcer.beforeHook(
inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(0)
);
}

// should FAIL to INVOKE invalid method
function test_methodFailsIfInvokesInvalidMethod() public {
uint256 initialLimit = 1 ether;
uint256 amountPerSecond = 0.1 ether;
uint256 startTime = block.timestamp;

// Create the execution that would be executed with invalid method
Execution memory execution_ = Execution({
target: address(basicERC20),
value: 0,
callData: abi.encodeWithSelector(IERC20.approve.selector, address(users.bob.deleGator), 1 ether)
});
bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData);

bytes memory inputTerms_ = abi.encodePacked(address(basicERC20), initialLimit, amountPerSecond, startTime);
bytes32 delegationHash_ = keccak256("test");

vm.prank(address(delegationManager));
vm.expectRevert("StreamingERC20Enforcer:invalid-method");
streamingERC20Enforcer.beforeHook(
inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(0)
);
}

////////////////////// Integration //////////////////////

// should FAIL to INVOKE invalid ERC20-contract
function test_methodFailsIfInvokesInvalidContract() public {
uint256 initialLimit = 1 ether;
uint256 amountPerSecond = 0.1 ether;
uint256 startTime = block.timestamp;

// Create the execution that would be executed
Execution memory execution_ = Execution({
target: address(basicERC20),
value: 0,
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), 0.5 ether)
});
bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData);

bytes memory inputTerms_ = abi.encodePacked(address(invalidERC20), initialLimit, amountPerSecond, startTime);
bytes32 delegationHash_ = keccak256("test");

vm.prank(address(delegationManager));
vm.expectRevert("StreamingERC20Enforcer:invalid-contract");
streamingERC20Enforcer.beforeHook(
inputTerms_, hex"", mode, executionCallData_, delegationHash_, address(0), address(0)
);
}

function _getEnforcer() internal view override returns (ICaveatEnforcer) {
return ICaveatEnforcer(address(streamingERC20Enforcer));
}
}

0 comments on commit aa8b0a1

Please sign in to comment.