diff --git a/contracts/treasury/Collector.sol b/contracts/treasury/Collector.sol index 7c0af711..87545834 100644 --- a/contracts/treasury/Collector.sol +++ b/contracts/treasury/Collector.sol @@ -1,26 +1,59 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.10; +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; -import {VersionedInitializable} from '@aave/core-v3/contracts/protocol/libraries/aave-upgradeability/VersionedInitializable.sol'; import {IERC20} from '@aave/core-v3/contracts/dependencies/openzeppelin/contracts/IERC20.sol'; +import {VersionedInitializable} from './libs/VersionedInitializable.sol'; +import {SafeERC20} from './libs/SafeERC20.sol'; +import {ReentrancyGuard} from './libs/ReentrancyGuard.sol'; +import {Address} from './libs/Address.sol'; import {ICollector} from './interfaces/ICollector.sol'; /** * @title Collector - * @notice Stores the fees collected by the protocol and allows the fund administrator - * to approve or transfer the collected ERC20 tokens. - * @dev Implementation contract that must be initialized using transparent proxy pattern. - * @author Aave + * @notice Stores ERC20 tokens of an ecosystem reserve and allows to dispose of them via approval + * or transfer dynamics or streaming capabilities. + * Modification of Sablier https://github.com/sablierhq/sablier/blob/develop/packages/protocol/contracts/Sablier.sol + * Original can be found also deployed on https://etherscan.io/address/0xCD18eAa163733Da39c232722cBC4E8940b1D8888 + * Modifications: + * - Sablier "pulls" the funds from the creator of the stream at creation. In the Aave case, we already have the funds. + * - Anybody can create streams on Sablier. Here, only the funds admin (Aave governance via controller) can + * - Adapted codebase to Solidity 0.8.11, mainly removing SafeMath and CarefulMath to use native safe math + * - 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 { - // Store the current funds administrator address +contract Collector is VersionedInitializable, ICollector, ReentrancyGuard { + using SafeERC20 for IERC20; + using Address for address payable; + + /*** Storage Properties ***/ + + /** + * @notice Address of the current funds admin. + */ address internal _fundsAdmin; - // Revision version of this implementation contract - uint256 public constant REVISION = 1; + /** + * @notice Current revision of the contract. + */ + uint256 public constant REVISION = 5; + + /** + * @notice Counter for new stream ids. + */ + uint256 private _nextStreamId; + + /** + * @notice The stream objects identifiable by their unsigned integer ids. + */ + mapping(uint256 => Stream) private _streams; + + /// @inheritdoc ICollector + address public constant ETH_MOCK_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /*** Modifiers ***/ /** - * @dev Allow only the funds administrator address to call functions marked by this modifier + * @dev Throws if the caller is not the funds admin. */ modifier onlyFundsAdmin() { require(msg.sender == _fundsAdmin, 'ONLY_BY_FUNDS_ADMIN'); @@ -28,13 +61,40 @@ contract Collector is VersionedInitializable, ICollector { } /** - * @dev Initialize the transparent proxy with the admin of the Collector - * @param reserveController The address of the admin that controls Collector + * @dev Throws if the caller is not the funds admin of the recipient of the stream. + * @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' + ); + _; + } + + /** + * @dev Throws if the provided id does not point to a valid stream. */ - function initialize(address reserveController) external initializer { - _setFundsAdmin(reserveController); + modifier streamExists(uint256 streamId) { + require(_streams[streamId].isEntity, 'stream does not exist'); + _; + } + + /*** Contract Logic Starts Here */ + + /// @inheritdoc ICollector + function initialize(address fundsAdmin, uint256 nextStreamId) external initializer { + if (nextStreamId != 0) { + _nextStreamId = nextStreamId; + } + + // can be removed after first deployment + _initGuard(); + _setFundsAdmin(fundsAdmin); } + /*** View Functions ***/ + /// @inheritdoc VersionedInitializable function getRevision() internal pure override returns (uint256) { return REVISION; @@ -45,13 +105,98 @@ contract Collector is VersionedInitializable, ICollector { return _fundsAdmin; } + /// @inheritdoc ICollector + function getNextStreamId() external view returns (uint256) { + return _nextStreamId; + } + + /// @inheritdoc ICollector + function getStream(uint256 streamId) + external + view + streamExists(streamId) + returns ( + address sender, + address recipient, + uint256 deposit, + address tokenAddress, + uint256 startTime, + uint256 stopTime, + uint256 remainingBalance, + uint256 ratePerSecond + ) + { + sender = _streams[streamId].sender; + recipient = _streams[streamId].recipient; + deposit = _streams[streamId].deposit; + tokenAddress = _streams[streamId].tokenAddress; + startTime = _streams[streamId].startTime; + stopTime = _streams[streamId].stopTime; + remainingBalance = _streams[streamId].remainingBalance; + ratePerSecond = _streams[streamId].ratePerSecond; + } + + /** + * @notice Returns either the delta in seconds between `block.timestamp` and `startTime` or + * between `stopTime` and `startTime, whichever is smaller. If `block.timestamp` is before + * `startTime`, it returns 0. + * @dev Throws if the id does not point to a valid stream. + * @param streamId The id of the stream for which to query the delta. + * @notice Returns the time delta in seconds. + */ + function deltaOf(uint256 streamId) public view streamExists(streamId) returns (uint256 delta) { + Stream memory stream = _streams[streamId]; + if (block.timestamp <= stream.startTime) return 0; + if (block.timestamp < stream.stopTime) return block.timestamp - stream.startTime; + return stream.stopTime - stream.startTime; + } + + struct BalanceOfLocalVars { + uint256 recipientBalance; + uint256 withdrawalAmount; + uint256 senderBalance; + } + + /// @inheritdoc ICollector + function balanceOf(uint256 streamId, address who) + public + view + streamExists(streamId) + returns (uint256 balance) + { + Stream memory stream = _streams[streamId]; + BalanceOfLocalVars memory vars; + + uint256 delta = deltaOf(streamId); + vars.recipientBalance = delta * stream.ratePerSecond; + + /* + * If the stream `balance` does not equal `deposit`, it means there have been withdrawals. + * We have to subtract the total amount withdrawn from the amount of money that has been + * streamed until now. + */ + if (stream.deposit > stream.remainingBalance) { + vars.withdrawalAmount = stream.deposit - stream.remainingBalance; + vars.recipientBalance = vars.recipientBalance - vars.withdrawalAmount; + } + + if (who == stream.recipient) return vars.recipientBalance; + if (who == stream.sender) { + vars.senderBalance = stream.remainingBalance - vars.recipientBalance; + return vars.senderBalance; + } + return 0; + } + + /*** Public Effects & Interactions Functions ***/ + /// @inheritdoc ICollector function approve( IERC20 token, address recipient, uint256 amount ) external onlyFundsAdmin { - token.approve(recipient, amount); + token.safeApprove(recipient, amount); } /// @inheritdoc ICollector @@ -60,9 +205,18 @@ contract Collector is VersionedInitializable, ICollector { address recipient, uint256 amount ) external onlyFundsAdmin { - token.transfer(recipient, amount); + require(recipient != address(0), 'INVALID_0X_RECIPIENT'); + + if (address(token) == ETH_MOCK_ADDRESS) { + payable(recipient).sendValue(amount); + } else { + token.safeTransfer(recipient, amount); + } } + /// @dev needed in order to receive ETH from the Aave v1 ecosystem reserve + receive() external payable {} + /// @inheritdoc ICollector function setFundsAdmin(address admin) external onlyFundsAdmin { _setFundsAdmin(admin); @@ -76,4 +230,132 @@ contract Collector is VersionedInitializable, ICollector { _fundsAdmin = admin; emit NewFundsAdmin(admin); } + + struct CreateStreamLocalVars { + uint256 duration; + uint256 ratePerSecond; + } + + /// @inheritdoc ICollector + /** + * @dev Throws if the recipient is the zero address, the contract itself or the caller. + * Throws if the deposit is 0. + * Throws if the start time is before `block.timestamp`. + * Throws if the stop time is before the start time. + * Throws if the duration calculation has a math error. + * Throws if the deposit is smaller than the duration. + * Throws if the deposit is not a multiple of the duration. + * Throws if the rate calculation has a math error. + * Throws if the next stream id calculation has a math error. + * Throws if the contract is not allowed to transfer enough tokens. + * Throws if there is a token transfer failure. + */ + function createStream( + address recipient, + uint256 deposit, + address tokenAddress, + 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'); + + 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'); + + /* This condition avoids dealing with remainders */ + require(deposit % vars.duration == 0, 'deposit not multiple of time delta'); + + vars.ratePerSecond = deposit / vars.duration; + + /* Create and store the stream object. */ + uint256 streamId = _nextStreamId; + _streams[streamId] = Stream({ + remainingBalance: deposit, + deposit: deposit, + isEntity: true, + ratePerSecond: vars.ratePerSecond, + recipient: recipient, + sender: address(this), + startTime: startTime, + stopTime: stopTime, + tokenAddress: tokenAddress + }); + + /* Increment the next stream id. */ + _nextStreamId++; + + emit CreateStream( + streamId, + address(this), + recipient, + deposit, + tokenAddress, + startTime, + stopTime + ); + return streamId; + } + + /// @inheritdoc ICollector + /** + * @dev Throws if the id does not point to a valid stream. + * Throws if the caller is not the funds admin or the recipient of the stream. + * Throws if the amount exceeds the available balance. + * Throws if there is a token transfer failure. + */ + function withdrawFromStream(uint256 streamId, uint256 amount) + external + nonReentrant + streamExists(streamId) + onlyAdminOrRecipient(streamId) + returns (bool) + { + require(amount > 0, 'amount is zero'); + Stream memory stream = _streams[streamId]; + + uint256 balance = balanceOf(streamId, stream.recipient); + require(balance >= amount, 'amount exceeds the available balance'); + + _streams[streamId].remainingBalance = stream.remainingBalance - amount; + + if (_streams[streamId].remainingBalance == 0) delete _streams[streamId]; + + IERC20(stream.tokenAddress).safeTransfer(stream.recipient, amount); + emit WithdrawFromStream(streamId, stream.recipient, amount); + return true; + } + + /// @inheritdoc ICollector + /** + * @dev Throws if the id does not point to a valid stream. + * Throws if the caller is not the funds admin or the recipient of the stream. + * Throws if there is a token transfer failure. + */ + function cancelStream(uint256 streamId) + external + nonReentrant + streamExists(streamId) + onlyAdminOrRecipient(streamId) + returns (bool) + { + Stream memory stream = _streams[streamId]; + uint256 senderBalance = balanceOf(streamId, stream.sender); + uint256 recipientBalance = balanceOf(streamId, stream.recipient); + + delete _streams[streamId]; + + IERC20 token = IERC20(stream.tokenAddress); + if (recipientBalance > 0) token.safeTransfer(stream.recipient, recipientBalance); + + emit CancelStream(streamId, stream.sender, stream.recipient, senderBalance, recipientBalance); + return true; + } } diff --git a/contracts/treasury/CollectorController.sol b/contracts/treasury/CollectorController.sol deleted file mode 100644 index bcd99a88..00000000 --- a/contracts/treasury/CollectorController.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.10; - -import {Ownable} from '@aave/core-v3/contracts/dependencies/openzeppelin/contracts/Ownable.sol'; -import {IERC20} from '@aave/core-v3/contracts/dependencies/openzeppelin/contracts/IERC20.sol'; -import {ICollector} from './interfaces/ICollector.sol'; - -/** - * @title CollectorController - * @notice The CollectorController contracts allows the owner of the contract - to approve or transfer tokens from the specified collector proxy contract. - The admin of the Collector proxy can't be the same as the fundsAdmin address. - This is needed due the usage of transparent proxy pattern. - * @author Aave - **/ -contract CollectorController is Ownable { - /** - * @dev Constructor setups the ownership of the contract - * @param owner The address of the owner of the CollectorController - */ - constructor(address owner) { - transferOwnership(owner); - } - - /** - * @dev Transfer an amount of tokens to the recipient. - * @param collector The address of the collector contract - * @param token The address of the asset - * @param recipient The address of the entity to transfer the tokens. - * @param amount The amount to be transferred. - */ - function approve( - address collector, - IERC20 token, - address recipient, - uint256 amount - ) external onlyOwner { - ICollector(collector).approve(token, recipient, amount); - } - - /** - * @dev Transfer an amount of tokens to the recipient. - * @param collector The address of the collector contract to retrieve funds from (e.g. Aave ecosystem reserve) - * @param token The address of the asset - * @param recipient The address of the entity to transfer the tokens. - * @param amount The amount to be transferred. - */ - function transfer( - address collector, - IERC20 token, - address recipient, - uint256 amount - ) external onlyOwner { - ICollector(collector).transfer(token, recipient, amount); - } -} diff --git a/contracts/treasury/interfaces/ICollector.sol b/contracts/treasury/interfaces/ICollector.sol index 8ea4bd1e..b6844f21 100644 --- a/contracts/treasury/interfaces/ICollector.sol +++ b/contracts/treasury/interfaces/ICollector.sol @@ -9,30 +9,97 @@ import {IERC20} from '@aave/core-v3/contracts/dependencies/openzeppelin/contract * @author Aave **/ interface ICollector { - /** - * @dev Emitted during the transfer of ownership of the funds administrator address - * @param fundsAdmin The new funds administrator address + struct Stream { + uint256 deposit; + uint256 ratePerSecond; + uint256 remainingBalance; + uint256 startTime; + uint256 stopTime; + address recipient; + address sender; + address tokenAddress; + bool isEntity; + } + + /** @notice Emitted when the funds admin changes + * @param fundsAdmin The new funds admin. **/ event NewFundsAdmin(address indexed fundsAdmin); + /** @notice Emitted when the new stream is created + * @param streamId The identifier of the stream. + * @param sender The address of the collector. + * @param recipient The address towards which the money is streamed. + * @param deposit The amount of money to be streamed. + * @param tokenAddress The ERC20 token to use as streaming currency. + * @param startTime The unix timestamp for when the stream starts. + * @param stopTime The unix timestamp for when the stream stops. + **/ + event CreateStream( + uint256 indexed streamId, + address indexed sender, + address indexed recipient, + uint256 deposit, + address tokenAddress, + uint256 startTime, + uint256 stopTime + ); + /** - * @dev Retrieve the current implementation Revision of the proxy - * @return The revision version + * @notice Emmitted when withdraw happens from the contract to the recipient's account. + * @param streamId The id of the stream to withdraw tokens from. + * @param recipient The address towards which the money is streamed. + * @param amount The amount of tokens to withdraw. */ - function REVISION() external view returns (uint256); + event WithdrawFromStream(uint256 indexed streamId, address indexed recipient, uint256 amount); /** - * @dev Retrieve the current funds administrator - * @return The address of the funds administrator + * @notice Emmitted when the stream is canceled. + * @param streamId The id of the stream to withdraw tokens from. + * @param sender The address of the collector. + * @param recipient The address towards which the money is streamed. + * @param senderBalance The sender's balance at the moment of cancelling. + * @param recipientBalance The recipient's balance at the moment of cancelling. */ + event CancelStream( + uint256 indexed streamId, + address indexed sender, + address indexed recipient, + uint256 senderBalance, + uint256 recipientBalance + ); + + /** @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 + **/ function getFundsAdmin() external view returns (address); /** - * @dev Approve an amount of tokens to be pulled by the recipient. - * @param token The address of the asset - * @param recipient The address of the entity allowed to pull tokens - * @param amount The amount allowed to be pulled. If zero it will revoke the approval. + * @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); + + /** + * @dev Function for the funds admin to give ERC20 allowance to other parties + * @param token The address of the token to give allowance from + * @param recipient Allowance's recipient + * @param amount Allowance to approve + **/ function approve( IERC20 token, address recipient, @@ -40,11 +107,11 @@ interface ICollector { ) external; /** - * @dev Transfer an amount of tokens to the recipient. - * @param token The address of the asset - * @param recipient The address of the entity to transfer the tokens. - * @param amount The amount to be transferred. - */ + * @notice Function for the funds admin to transfer ERC20 tokens to other parties + * @param token The address of the token to transfer + * @param recipient Transfer's recipient + * @param amount Amount to transfer + **/ function transfer( IERC20 token, address recipient, @@ -57,4 +124,62 @@ interface ICollector { * @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. + * @param deposit The amount of money to be streamed. + * @param tokenAddress The ERC20 token to use as streaming currency. + * @param startTime The unix timestamp for when the stream starts. + * @param stopTime The unix timestamp for when the stream stops. + * @return streamId the uint256 id of the newly created stream. + */ + function createStream( + address recipient, + uint256 deposit, + address tokenAddress, + uint256 startTime, + uint256 stopTime + ) external returns (uint256 streamId); + + /** + * @notice Returns the stream with all its properties. + * @dev Throws if the id does not point to a valid stream. + * @param streamId The id of the stream to query. + * @notice Returns the stream object. + */ + function getStream(uint256 streamId) + external + view + returns ( + address sender, + address recipient, + uint256 deposit, + address tokenAddress, + uint256 startTime, + uint256 stopTime, + uint256 remainingBalance, + uint256 ratePerSecond + ); + + /** + * @notice Withdraws from the contract to the recipient's account. + * @param streamId The id of the stream to withdraw tokens from. + * @param amount The amount of tokens to withdraw. + * @return bool Returns true if successful. + */ + function withdrawFromStream(uint256 streamId, uint256 amount) external returns (bool); + + /** + * @notice Cancels the stream and transfers the tokens back on a pro rata basis. + * @param streamId The id of the stream to cancel. + * @return bool Returns true if successful. + */ + function cancelStream(uint256 streamId) external returns (bool); + + /** + * @notice Returns the next available stream id + * @return nextStreamId Returns the stream id. + */ + function getNextStreamId() external view returns (uint256); } diff --git a/contracts/treasury/libs/ReentrancyGuard.sol b/contracts/treasury/libs/ReentrancyGuard.sol index d72d1723..d8b54911 100644 --- a/contracts/treasury/libs/ReentrancyGuard.sol +++ b/contracts/treasury/libs/ReentrancyGuard.sol @@ -60,4 +60,11 @@ abstract contract ReentrancyGuard { // https://eips.ethereum.org/EIPS/eip-2200) _status = _NOT_ENTERED; } + + /** + * @dev As we use the guard with the proxy we need to init it with the empty value + */ + function _initGuard() internal { + _status = _NOT_ENTERED; + } }