diff --git a/audits/2025-01-20_Certora_CollectorRev6.pdf b/audits/2025-01-20_Certora_CollectorRev6.pdf new file mode 100644 index 00000000..74233925 Binary files /dev/null and b/audits/2025-01-20_Certora_CollectorRev6.pdf differ diff --git a/src/contracts/treasury/Collector.sol b/src/contracts/treasury/Collector.sol index a42ac0ac..a04191b2 100644 --- a/src/contracts/treasury/Collector.sol +++ b/src/contracts/treasury/Collector.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; +import {AccessControlUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol'; +import {ReentrancyGuardUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import {Address} from 'openzeppelin-contracts/contracts/utils/Address.sol'; import {ICollector} from './ICollector.sol'; -import {ReentrancyGuard} from '../dependencies/openzeppelin/ReentrancyGuard.sol'; -import {VersionedInitializable} from '../misc/aave-upgradeability/VersionedInitializable.sol'; -import {IERC20} from '../dependencies/openzeppelin/contracts/IERC20.sol'; -import {SafeERC20} from '../dependencies/openzeppelin/contracts/SafeERC20.sol'; -import {Address} from '../dependencies/openzeppelin/contracts/Address.sol'; /** * @title Collector @@ -21,21 +21,25 @@ import {Address} from '../dependencies/openzeppelin/contracts/Address.sol'; * - Same as with creation, on Sablier the `sender` and `recipient` can cancel a stream. Here, only fund admin and recipient * @author BGD Labs **/ -contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { +contract Collector is AccessControlUpgradeable, ReentrancyGuardUpgradeable, ICollector { using SafeERC20 for IERC20; using Address for address payable; /*** Storage Properties ***/ + /// @inheritdoc ICollector + address public constant ETH_MOCK_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - /** - * @notice Address of the current funds admin. - */ - address internal _fundsAdmin; + /// @inheritdoc ICollector + bytes32 public constant FUNDS_ADMIN_ROLE = 'FUNDS_ADMIN'; - /** - * @notice Current revision of the contract. - */ - uint256 public constant REVISION = 5; + // Reserved storage space to account for deprecated inherited storage + // 0 was lastInitializedRevision + // 1-50 were the ____gap + // 51 was the reentrancy guard _status + // 52 was the _fundsAdmin + // On some networks the layout was shifted by 1 due to `initializing` being on slot 1 + // The upgrade proposal would in this case manually shift the storage layout to properly align the networks + uint256[53] private ______gap; /** * @notice Counter for new stream ids. @@ -47,16 +51,15 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { */ mapping(uint256 => Stream) private _streams; - /// @inheritdoc ICollector - address public constant ETH_MOCK_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - /*** Modifiers ***/ /** - * @dev Throws if the caller is not the funds admin. + * @dev Throws if the caller does not have the FUNDS_ADMIN role */ modifier onlyFundsAdmin() { - require(msg.sender == _fundsAdmin, 'ONLY_BY_FUNDS_ADMIN'); + if (_onlyFundsAdmin() == false) { + revert OnlyFundsAdmin(); + } _; } @@ -65,10 +68,9 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { * @param streamId The id of the stream to query. */ modifier onlyAdminOrRecipient(uint256 streamId) { - require( - msg.sender == _fundsAdmin || msg.sender == _streams[streamId].recipient, - 'caller is not the funds admin or the recipient of the stream' - ); + if (_onlyFundsAdmin() == false && msg.sender != _streams[streamId].recipient) { + revert OnlyFundsAdminOrRecipient(); + } _; } @@ -76,32 +78,33 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { * @dev Throws if the provided id does not point to a valid stream. */ modifier streamExists(uint256 streamId) { - require(_streams[streamId].isEntity, 'stream does not exist'); + if (!_streams[streamId].isEntity) revert StreamDoesNotExist(); _; } + constructor() { + _disableInitializers(); + } + /*** Contract Logic Starts Here */ - /// @inheritdoc ICollector - function initialize(address fundsAdmin, uint256 nextStreamId) external initializer { + /** @notice Initializes the contracts + * @param nextStreamId StreamId to set, applied if greater than 0 + * @param admin The default admin managing the FundsAdmins + **/ + function initialize(uint256 nextStreamId, address admin) external virtual initializer { + __AccessControl_init(); + __ReentrancyGuard_init(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); if (nextStreamId != 0) { _nextStreamId = nextStreamId; } - - _initGuard(); - _setFundsAdmin(fundsAdmin); } /*** View Functions ***/ - - /// @inheritdoc VersionedInitializable - function getRevision() internal pure override returns (uint256) { - return REVISION; - } - /// @inheritdoc ICollector - function getFundsAdmin() external view returns (address) { - return _fundsAdmin; + function isFundsAdmin(address admin) external view returns (bool) { + return hasRole(FUNDS_ADMIN_ROLE, admin); } /// @inheritdoc ICollector @@ -191,12 +194,12 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { /// @inheritdoc ICollector function approve(IERC20 token, address recipient, uint256 amount) external onlyFundsAdmin { - token.safeApprove(recipient, amount); + token.forceApprove(recipient, amount); } /// @inheritdoc ICollector function transfer(IERC20 token, address recipient, uint256 amount) external onlyFundsAdmin { - require(recipient != address(0), 'INVALID_0X_RECIPIENT'); + if (recipient == address(0)) revert InvalidZeroAddress(); if (address(token) == ETH_MOCK_ADDRESS) { payable(recipient).sendValue(amount); @@ -205,18 +208,8 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { } } - /// @inheritdoc ICollector - function setFundsAdmin(address admin) external onlyFundsAdmin { - _setFundsAdmin(admin); - } - - /** - * @dev Transfer the ownership of the funds administrator role. - * @param admin The address of the new funds administrator - */ - function _setFundsAdmin(address admin) internal { - _fundsAdmin = admin; - emit NewFundsAdmin(admin); + function _onlyFundsAdmin() internal view returns (bool) { + return hasRole(FUNDS_ADMIN_ROLE, msg.sender); } struct CreateStreamLocalVars { @@ -245,21 +238,21 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { uint256 startTime, uint256 stopTime ) external onlyFundsAdmin returns (uint256) { - require(recipient != address(0), 'stream to the zero address'); - require(recipient != address(this), 'stream to the contract itself'); - require(recipient != msg.sender, 'stream to the caller'); - require(deposit > 0, 'deposit is zero'); - require(startTime >= block.timestamp, 'start time before block.timestamp'); - require(stopTime > startTime, 'stop time before the start time'); + if (recipient == address(0)) revert InvalidZeroAddress(); + if (recipient == address(this)) revert InvalidRecipient(); + if (recipient == msg.sender) revert InvalidRecipient(); + if (deposit == 0) revert InvalidZeroAmount(); + if (startTime < block.timestamp) revert InvalidStartTime(); + if (stopTime <= startTime) revert InvalidStopTime(); CreateStreamLocalVars memory vars; vars.duration = stopTime - startTime; /* Without this, the rate per second would be zero. */ - require(deposit >= vars.duration, 'deposit smaller than time delta'); + if (deposit < vars.duration) revert DepositSmallerTimeDelta(); /* This condition avoids dealing with remainders */ - require(deposit % vars.duration == 0, 'deposit not multiple of time delta'); + if (deposit % vars.duration > 0) revert DepositNotMultipleTimeDelta(); vars.ratePerSecond = deposit / vars.duration; @@ -303,11 +296,11 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { uint256 streamId, uint256 amount ) external nonReentrant streamExists(streamId) onlyAdminOrRecipient(streamId) returns (bool) { - require(amount > 0, 'amount is zero'); + if (amount == 0) revert InvalidZeroAmount(); Stream memory stream = _streams[streamId]; uint256 balance = balanceOf(streamId, stream.recipient); - require(balance >= amount, 'amount exceeds the available balance'); + if (balance < amount) revert BalanceExceeded(); _streams[streamId].remainingBalance = stream.remainingBalance - amount; @@ -339,4 +332,7 @@ contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { emit CancelStream(streamId, stream.sender, stream.recipient, senderBalance, recipientBalance); return true; } + + /// @dev needed in order to receive ETH from the Aave v1 ecosystem reserve + receive() external payable {} } diff --git a/src/contracts/treasury/ICollector.sol b/src/contracts/treasury/ICollector.sol index 94103d48..cb41b3f7 100644 --- a/src/contracts/treasury/ICollector.sol +++ b/src/contracts/treasury/ICollector.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import {IERC20} from '../dependencies/openzeppelin/contracts/IERC20.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; interface ICollector { struct Stream { @@ -16,10 +16,60 @@ interface ICollector { bool isEntity; } - /** @notice Emitted when the funds admin changes - * @param fundsAdmin The new funds admin. - **/ - event NewFundsAdmin(address indexed fundsAdmin); + /** + * @dev Withdraw amount exceeds available balance + */ + error BalanceExceeded(); + + /** + * @dev Deposit smaller than time delta + */ + error DepositSmallerTimeDelta(); + + /** + * @dev Deposit not multiple of time delta + */ + error DepositNotMultipleTimeDelta(); + + /** + * @dev Recipient cannot be the contract itself or msg.sender + */ + error InvalidRecipient(); + + /** + * @dev Start time cannot be before block.timestamp + */ + error InvalidStartTime(); + + /** + * @dev Stop time must be greater than startTime + */ + error InvalidStopTime(); + + /** + * @dev Provided address cannot be the zero-address + */ + error InvalidZeroAddress(); + + /** + * @dev Amount cannot be zero + */ + error InvalidZeroAmount(); + + /** + * @dev Only caller with FUNDS_ADMIN role can call + */ + error OnlyFundsAdmin(); + + /** + * @dev Only caller with FUNDS_ADMIN role or stream recipient can call + */ + error OnlyFundsAdminOrRecipient(); + + /** + * @dev The provided ID does not belong to an existing stream + */ + error StreamDoesNotExist(); /** @notice Emitted when the new stream is created * @param streamId The identifier of the stream. @@ -64,29 +114,28 @@ interface ICollector { uint256 recipientBalance ); + /** + * @notice FUNDS_ADMIN role granted by ACL Manager + **/ + function FUNDS_ADMIN_ROLE() external view returns (bytes32); + /** @notice Returns the mock ETH reference address * @return address The address **/ function ETH_MOCK_ADDRESS() external pure returns (address); - /** @notice Initializes the contracts - * @param fundsAdmin Funds admin address - * @param nextStreamId StreamId to set, applied if greater than 0 - **/ - function initialize(address fundsAdmin, uint256 nextStreamId) external; - /** - * @notice Return the funds admin, only entity to be able to interact with this contract (controller of reserve) - * @return address The address of the funds admin + * @notice Checks if address is funds admin + * @return bool If the address has the funds admin role **/ - function getFundsAdmin() external view returns (address); + function isFundsAdmin(address admin) external view returns (bool); /** * @notice Returns the available funds for the given stream id and address. * @param streamId The id of the stream for which to query the balance. * @param who The address for which to query the balance. * @notice Returns the total funds allocated to `who` as uint256. - */ + **/ function balanceOf(uint256 streamId, address who) external view returns (uint256 balance); /** @@ -105,13 +154,6 @@ interface ICollector { **/ function transfer(IERC20 token, address recipient, uint256 amount) external; - /** - * @dev Transfer the ownership of the funds administrator role. - This function should only be callable by the current funds administrator. - * @param admin The address of the new funds administrator - */ - function setFundsAdmin(address admin) external; - /** * @notice Creates a new stream funded by this contracts itself and paid towards `recipient`. * @param recipient The address towards which the money is streamed. diff --git a/src/deployments/contracts/procedures/AaveV3TreasuryProcedure.sol b/src/deployments/contracts/procedures/AaveV3TreasuryProcedure.sol index a80aa754..f4ffe2a9 100644 --- a/src/deployments/contracts/procedures/AaveV3TreasuryProcedure.sol +++ b/src/deployments/contracts/procedures/AaveV3TreasuryProcedure.sol @@ -17,38 +17,27 @@ contract AaveV3TreasuryProcedure { ) internal returns (TreasuryReport memory) { TreasuryReport memory treasuryReport; bytes32 salt = collectorSalt; - address treasuryOwner = poolAdmin; if (salt != '') { Collector treasuryImplementation = new Collector{salt: salt}(); - treasuryImplementation.initialize(address(0), 0); treasuryReport.treasuryImplementation = address(treasuryImplementation); treasuryReport.treasury = address( new TransparentUpgradeableProxy{salt: salt}( treasuryReport.treasuryImplementation, poolAdmin, - abi.encodeWithSelector( - treasuryImplementation.initialize.selector, - address(treasuryOwner), - 0 - ) + abi.encodeWithSelector(treasuryImplementation.initialize.selector, 100_000, poolAdmin) ) ); } else { Collector treasuryImplementation = new Collector(); - treasuryImplementation.initialize(address(0), 0); treasuryReport.treasuryImplementation = address(treasuryImplementation); treasuryReport.treasury = address( new TransparentUpgradeableProxy( treasuryReport.treasuryImplementation, poolAdmin, - abi.encodeWithSelector( - treasuryImplementation.initialize.selector, - address(treasuryOwner), - 100_000 - ) + abi.encodeWithSelector(treasuryImplementation.initialize.selector, 100_000, poolAdmin) ) ); } diff --git a/tests/treasury/Collector.t.sol b/tests/treasury/Collector.t.sol new file mode 100644 index 00000000..f58630d7 --- /dev/null +++ b/tests/treasury/Collector.t.sol @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; +import {StdUtils} from 'forge-std/StdUtils.sol'; + +import {TransparentUpgradeableProxy} from 'openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {IAccessControl} from '../../src/contracts/dependencies/openzeppelin/contracts/IAccessControl.sol'; +import {PoolAddressesProvider} from '../../src/contracts/protocol/configuration/PoolAddressesProvider.sol'; +import {Collector} from '../../src/contracts/treasury/Collector.sol'; +import {ICollector} from '../../src/contracts/treasury/ICollector.sol'; + +contract CollectorTest is StdUtils, Test { + Collector public collector; + + address public EXECUTOR_LVL_1; + address public ACL_ADMIN; + address public RECIPIENT_STREAM_1; + address public FUNDS_ADMIN; + address public OWNER; + + IERC20 tokenA; + IERC20 tokenB; + + uint256 public streamStartTime; + uint256 public streamStopTime; + uint256 public nextStreamID = 100_000; + + event StreamIdChanged(uint256 indexed streamId); + event CreateStream( + uint256 indexed streamId, + address indexed sender, + address indexed recipient, + uint256 deposit, + address tokenAddress, + uint256 startTime, + uint256 stopTime + ); + event CancelStream( + uint256 indexed streamId, + address indexed sender, + address indexed recipient, + uint256 senderBalance, + uint256 recipientBalance + ); + event WithdrawFromStream(uint256 indexed streamId, address indexed recipient, uint256 amount); + + function setUp() public { + EXECUTOR_LVL_1 = makeAddr('governance'); + FUNDS_ADMIN = makeAddr('funds-admin'); + OWNER = makeAddr('owner'); + RECIPIENT_STREAM_1 = makeAddr('recipient'); + + PoolAddressesProvider provider = new PoolAddressesProvider('aave', OWNER); + vm.prank(OWNER); + provider.setACLAdmin(EXECUTOR_LVL_1); + + tokenA = IERC20(address(deployMockERC20('Token A', 'TK_A', 18))); + tokenB = IERC20(address(deployMockERC20('Token B', 'TK_B', 6))); + + streamStartTime = block.timestamp + 10; + streamStopTime = block.timestamp + 70; + nextStreamID = 0; + + address collectorImpl = address(new Collector()); + collector = Collector( + payable( + new TransparentUpgradeableProxy( + collectorImpl, + address(this), + abi.encodeWithSelector(Collector.initialize.selector, nextStreamID, EXECUTOR_LVL_1) + ) + ) + ); + + deal(address(tokenA), address(collector), 100 ether); + + vm.startPrank(EXECUTOR_LVL_1); + IAccessControl(address(collector)).grantRole(collector.FUNDS_ADMIN_ROLE(), FUNDS_ADMIN); + IAccessControl(address(collector)).grantRole(collector.FUNDS_ADMIN_ROLE(), EXECUTOR_LVL_1); + vm.stopPrank(); + } + + function testApprove() public { + vm.prank(FUNDS_ADMIN); + collector.approve(tokenA, address(42), 1 ether); + + uint256 allowance = tokenA.allowance(address(collector), address(42)); + + assertEq(allowance, 1 ether); + } + + function testApproveWhenNotFundsAdmin() public { + vm.expectRevert(ICollector.OnlyFundsAdmin.selector); + collector.approve(tokenA, address(0), 1 ether); + } + + function testTransfer() public { + vm.prank(FUNDS_ADMIN); + collector.transfer(tokenA, address(112), 1 ether); + + uint256 balance = tokenA.balanceOf(address(112)); + + assertEq(balance, 1 ether); + } + + function testTransferWhenNotFundsAdmin() public { + vm.expectRevert(ICollector.OnlyFundsAdmin.selector); + + collector.transfer(tokenA, address(112), 1 ether); + } + + function test_receiveEth() external { + deal(address(this), 1000 ether); + (bool success, ) = address(collector).call{value: 1000 ether}(new bytes(0)); + assertEq(success, true); + } +} + +contract StreamsTest is CollectorTest { + function testGetNextStreamId() public view { + uint256 streamId = collector.getNextStreamId(); + assertEq(streamId, nextStreamID); + } + + function testGetNotExistingStream() public { + vm.expectRevert(ICollector.StreamDoesNotExist.selector); + collector.getStream(nextStreamID + 1); + } + + // create stream + function testCreateStream() public { + vm.expectEmit(true, true, true, true); + emit CreateStream( + nextStreamID, + address(collector), + RECIPIENT_STREAM_1, + 6 ether, + address(tokenA), + streamStartTime, + streamStopTime + ); + + vm.startPrank(FUNDS_ADMIN); + uint256 streamId = createStream(); + + assertEq(streamId, nextStreamID); + + ( + address sender, + address recipient, + uint256 deposit, + address tokenAddress, + uint256 startTime, + uint256 stopTime, + uint256 remainingBalance, + + ) = collector.getStream(streamId); + + assertEq(sender, address(collector)); + assertEq(recipient, RECIPIENT_STREAM_1); + assertEq(deposit, 6 ether); + assertEq(tokenAddress, address(tokenA)); + assertEq(startTime, streamStartTime); + assertEq(stopTime, streamStopTime); + assertEq(remainingBalance, 6 ether); + } + + function testGetStream() public { + vm.prank(FUNDS_ADMIN); + uint256 streamId = createStream(); + (, , , , uint256 startTime, , , ) = collector.getStream(streamId); + assertEq(startTime, streamStartTime); + } + + function testCreateStreamWhenNotFundsAdmin() public { + vm.expectRevert(ICollector.OnlyFundsAdmin.selector); + + collector.createStream( + RECIPIENT_STREAM_1, + 6 ether, + address(tokenA), + streamStartTime, + streamStopTime + ); + } + + function testCreateStreamWhenRecipientIsZero() public { + vm.expectRevert(ICollector.InvalidZeroAddress.selector); + + vm.prank(FUNDS_ADMIN); + collector.createStream(address(0), 6 ether, address(tokenA), streamStartTime, streamStopTime); + } + + function testCreateStreamWhenRecipientIsCollector() public { + vm.expectRevert(ICollector.InvalidRecipient.selector); + + vm.prank(FUNDS_ADMIN); + collector.createStream( + address(collector), + 6 ether, + address(tokenA), + streamStartTime, + streamStopTime + ); + } + + function testCreateStreamWhenRecipientIsTheCaller() public { + vm.expectRevert(ICollector.InvalidRecipient.selector); + + vm.prank(FUNDS_ADMIN); + collector.createStream(FUNDS_ADMIN, 6 ether, address(tokenA), streamStartTime, streamStopTime); + } + + function testCreateStreamWhenDepositIsZero() public { + vm.expectRevert(ICollector.InvalidZeroAmount.selector); + + vm.prank(FUNDS_ADMIN); + collector.createStream( + RECIPIENT_STREAM_1, + 0 ether, + address(tokenA), + streamStartTime, + streamStopTime + ); + } + + function testCreateStreamWhenStartTimeInThePast() public { + vm.warp(block.timestamp + 100); + + vm.expectRevert(ICollector.InvalidStartTime.selector); + + vm.prank(FUNDS_ADMIN); + collector.createStream( + RECIPIENT_STREAM_1, + 6 ether, + address(tokenA), + block.timestamp - 10, + streamStopTime + ); + } + + function testCreateStreamWhenStopTimeBeforeStart() public { + vm.expectRevert(ICollector.InvalidStopTime.selector); + + vm.prank(FUNDS_ADMIN); + collector.createStream( + RECIPIENT_STREAM_1, + 6 ether, + address(tokenA), + block.timestamp + 70, + block.timestamp + 10 + ); + } + + // withdraw from stream + function testWithdrawFromStream() public { + vm.startPrank(FUNDS_ADMIN); + // Arrange + uint256 streamId = createStream(); + vm.stopPrank(); + + vm.warp(block.timestamp + 20); + + uint256 balanceRecipientBefore = tokenA.balanceOf(RECIPIENT_STREAM_1); + uint256 balanceRecipientStreamBefore = collector.balanceOf(streamId, RECIPIENT_STREAM_1); + uint256 balanceCollectorBefore = tokenA.balanceOf(address(collector)); + uint256 balanceCollectorStreamBefore = collector.balanceOf(streamId, address(collector)); + + vm.expectEmit(true, true, true, true); + emit WithdrawFromStream(streamId, RECIPIENT_STREAM_1, 1 ether); + + vm.prank(RECIPIENT_STREAM_1); + // Act + collector.withdrawFromStream(streamId, 1 ether); + + // Assert + uint256 balanceRecipientAfter = tokenA.balanceOf(RECIPIENT_STREAM_1); + uint256 balanceRecipientStreamAfter = collector.balanceOf(streamId, RECIPIENT_STREAM_1); + uint256 balanceCollectorAfter = tokenA.balanceOf(address(collector)); + uint256 balanceCollectorStreamAfter = collector.balanceOf(streamId, address(collector)); + + assertEq(balanceRecipientAfter, balanceRecipientBefore + 1 ether); + assertEq(balanceRecipientStreamAfter, balanceRecipientStreamBefore - 1 ether); + assertEq(balanceCollectorAfter, balanceCollectorBefore - 1 ether); + assertEq(balanceCollectorStreamAfter, balanceCollectorStreamBefore); + } + + function testWithdrawFromStreamFinishesSuccessfully() public { + vm.startPrank(FUNDS_ADMIN); + // Arrange + uint256 streamId = createStream(); + vm.stopPrank(); + + vm.warp(block.timestamp + 70); + + uint256 balanceRecipientBefore = tokenA.balanceOf(RECIPIENT_STREAM_1); + uint256 balanceCollectorBefore = tokenA.balanceOf(address(collector)); + + vm.expectEmit(true, true, true, true); + emit WithdrawFromStream(streamId, RECIPIENT_STREAM_1, 6 ether); + + vm.prank(RECIPIENT_STREAM_1); + // Act + collector.withdrawFromStream(streamId, 6 ether); + + // Assert + uint256 balanceRecipientAfter = tokenA.balanceOf(RECIPIENT_STREAM_1); + uint256 balanceCollectorAfter = tokenA.balanceOf(address(collector)); + + assertEq(balanceRecipientAfter, balanceRecipientBefore + 6 ether); + assertEq(balanceCollectorAfter, balanceCollectorBefore - 6 ether); + + vm.expectRevert(ICollector.StreamDoesNotExist.selector); + collector.getStream(streamId); + } + + function testWithdrawFromStreamWhenStreamNotExists() public { + vm.expectRevert(ICollector.StreamDoesNotExist.selector); + + collector.withdrawFromStream(nextStreamID, 1 ether); + } + + function testWithdrawFromStreamWhenNotAdminOrRecipient() public { + vm.prank(FUNDS_ADMIN); + uint256 streamId = createStream(); + + vm.expectRevert(ICollector.OnlyFundsAdminOrRecipient.selector); + collector.withdrawFromStream(streamId, 1 ether); + } + + function testWithdrawFromStreamWhenAmountIsZero() public { + vm.startPrank(FUNDS_ADMIN); + uint256 streamId = createStream(); + + vm.expectRevert(ICollector.InvalidZeroAmount.selector); + + collector.withdrawFromStream(streamId, 0 ether); + } + + function testWithdrawFromStreamWhenAmountExceedsBalance() public { + vm.prank(FUNDS_ADMIN); + uint256 streamId = collector.createStream( + RECIPIENT_STREAM_1, + 6 ether, + address(tokenA), + streamStartTime, + streamStopTime + ); + + vm.warp(block.timestamp + 20); + vm.expectRevert(ICollector.BalanceExceeded.selector); + + vm.prank(FUNDS_ADMIN); + collector.withdrawFromStream(streamId, 2 ether); + } + + // cancel stream + function testCancelStreamByFundsAdmin() public { + vm.prank(FUNDS_ADMIN); + // Arrange + uint256 streamId = createStream(); + uint256 balanceRecipientBefore = tokenA.balanceOf(RECIPIENT_STREAM_1); + + vm.expectEmit(true, true, true, true); + emit CancelStream(streamId, address(collector), RECIPIENT_STREAM_1, 6 ether, 0); + + vm.prank(FUNDS_ADMIN); + // Act + collector.cancelStream(streamId); + + // Assert + uint256 balanceRecipientAfter = tokenA.balanceOf(RECIPIENT_STREAM_1); + assertEq(balanceRecipientAfter, balanceRecipientBefore); + + vm.expectRevert(ICollector.StreamDoesNotExist.selector); + collector.getStream(streamId); + } + + function testCancelStreamByRecipient() public { + vm.prank(FUNDS_ADMIN); + // Arrange + uint256 streamId = createStream(); + uint256 balanceRecipientBefore = tokenA.balanceOf(RECIPIENT_STREAM_1); + + vm.warp(block.timestamp + 20); + + vm.expectEmit(true, true, true, true); + emit CancelStream(streamId, address(collector), RECIPIENT_STREAM_1, 5 ether, 1 ether); + + vm.prank(RECIPIENT_STREAM_1); + // Act + collector.cancelStream(streamId); + + // Assert + uint256 balanceRecipientAfter = tokenA.balanceOf(RECIPIENT_STREAM_1); + assertEq(balanceRecipientAfter, balanceRecipientBefore + 1 ether); + + vm.expectRevert(ICollector.StreamDoesNotExist.selector); + collector.getStream(streamId); + } + + function testCancelStreamWhenStreamNotExists() public { + vm.expectRevert(ICollector.StreamDoesNotExist.selector); + + collector.cancelStream(nextStreamID); + } + + function testCancelStreamWhenNotAdminOrRecipient() public { + vm.prank(FUNDS_ADMIN); + uint256 streamId = createStream(); + + vm.expectRevert(ICollector.OnlyFundsAdminOrRecipient.selector); + vm.prank(makeAddr('random')); + + collector.cancelStream(streamId); + } + + function createStream() private returns (uint256) { + return + collector.createStream( + RECIPIENT_STREAM_1, + 6 ether, + address(tokenA), + streamStartTime, + streamStopTime + ); + } +} + +contract FundsAdminRoleBytesTest is CollectorTest { + function test_successful() public view { + assertEq(collector.FUNDS_ADMIN_ROLE(), 'FUNDS_ADMIN'); + } +} + +contract IsFundsAdminTest is CollectorTest { + function test_isNotFundsAdmin() public { + assertFalse(collector.isFundsAdmin(makeAddr('not-funds-admin'))); + } + + function test_isFundsAdmin() public view { + assertTrue(collector.isFundsAdmin(FUNDS_ADMIN)); + assertTrue(collector.isFundsAdmin(EXECUTOR_LVL_1)); + } +}