From 1ceca952a09a3a91fb9864db5b36c517a4c85c86 Mon Sep 17 00:00:00 2001 From: smol-ninja Date: Tue, 21 May 2024 20:20:40 +0100 Subject: [PATCH] feat: add depositViaBroker and createAndDepositViaBroker --- src/SablierV2OpenEnded.sol | 96 +++++++++++++++++++-- src/abstracts/SablierV2OpenEndedState.sol | 4 + src/interfaces/ISablierV2OpenEnded.sol | 62 ++++++++++--- src/interfaces/ISablierV2OpenEndedState.sol | 8 +- src/libraries/Errors.sol | 11 ++- src/types/DataTypes.sol | 10 ++- test/integration/constructor.t.sol | 8 ++ 7 files changed, 179 insertions(+), 20 deletions(-) diff --git a/src/SablierV2OpenEnded.sol b/src/SablierV2OpenEnded.sol index 5571da4c..e265fb29 100644 --- a/src/SablierV2OpenEnded.sol +++ b/src/SablierV2OpenEnded.sol @@ -6,12 +6,13 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { ud } from "@prb/math/src/UD60x18.sol"; import { NoDelegateCall } from "./abstracts/NoDelegateCall.sol"; import { SablierV2OpenEndedState } from "./abstracts/SablierV2OpenEndedState.sol"; import { ISablierV2OpenEnded } from "./interfaces/ISablierV2OpenEnded.sol"; import { Errors } from "./libraries/Errors.sol"; -import { OpenEnded } from "./types/DataTypes.sol"; +import { Broker, OpenEnded } from "./types/DataTypes.sol"; /// @title SablierV2OpenEnded /// @notice See the documentation in {ISablierV2OpenEnded}. @@ -197,15 +198,38 @@ contract SablierV2OpenEnded is ) external override + noDelegateCall returns (uint256 streamId) { // Checks, Effects and Interactions: create the stream. - streamId = create(sender, recipient, ratePerSecond, asset, isTransferable); + streamId = _create(sender, recipient, ratePerSecond, asset, isTransferable); // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); } + /// @inheritdoc ISablierV2OpenEnded + function createAndDepositViaBroker( + address sender, + address recipient, + uint128 ratePerSecond, + IERC20 asset, + bool isTransferable, + uint128 totalAmount, + Broker calldata broker + ) + external + override + noDelegateCall + returns (uint256 streamId) + { + // Checks, Effects and Interactions: create the stream. + streamId = _create(sender, recipient, ratePerSecond, asset, isTransferable); + + // Checks, Effects and Interactions: deposit into stream through {depositViaBroker}. + _depositViaBroker(streamId, totalAmount, broker); + } + /// @inheritdoc ISablierV2OpenEnded function deposit( uint256 streamId, @@ -222,6 +246,23 @@ contract SablierV2OpenEnded is _deposit(streamId, amount); } + /// @inheritdoc ISablierV2OpenEnded + function depositViaBroker( + uint256 streamId, + uint128 totalAmount, + Broker calldata broker + ) + public + override + noDelegateCall + notNull(streamId) + notCanceled(streamId) + updateMetadata(streamId) + { + // Checks, Effects and Interactions: deposit on stream through broker. + _depositViaBroker(streamId, totalAmount, broker); + } + /// @inheritdoc ISablierV2OpenEnded function restartStream( uint256 streamId, @@ -239,9 +280,20 @@ contract SablierV2OpenEnded is } /// @inheritdoc ISablierV2OpenEnded - function restartStreamAndDeposit(uint256 streamId, uint128 ratePerSecond, uint128 amount) external override { + function restartStreamAndDeposit( + uint256 streamId, + uint128 ratePerSecond, + uint128 amount + ) + external + override + noDelegateCall + notNull(streamId) + onlySender(streamId) + updateMetadata(streamId) + { // Checks, Effects and Interactions: restart the stream. - restartStream(streamId, ratePerSecond); + _restartStream(streamId, ratePerSecond); // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); @@ -280,9 +332,18 @@ contract SablierV2OpenEnded is } /// @inheritdoc ISablierV2OpenEnded - function withdrawMax(uint256 streamId, address to) external override { + function withdrawMax( + uint256 streamId, + address to + ) + external + override + noDelegateCall + notNull(streamId) + updateMetadata(streamId) + { // Checks, Effects and Interactions: make the withdrawal. - withdrawAt(streamId, to, uint40(block.timestamp)); + _withdrawAt(streamId, to, uint40(block.timestamp)); } /*////////////////////////////////////////////////////////////////////////// @@ -544,6 +605,29 @@ contract SablierV2OpenEnded is emit ISablierV2OpenEnded.DepositOpenEndedStream(streamId, msg.sender, asset, amount); } + /// @dev See the documentation for the user-facing functions that call this internal function. + function _depositViaBroker(uint256 streamId, uint128 totalAmount, Broker memory broker) internal { + // Check: the broker's fee is not greater than `MAX_BROKER_FEE`. + if (broker.fee.gt(MAX_BROKER_FEE)) { + revert Errors.SablierV2OpenEnded_BrokerFeeTooHigh(streamId, broker.fee, MAX_BROKER_FEE); + } + + // Check: the broker recipient is not the zero address. + if (broker.account == address(0)) { + revert Errors.SablierV2OpenEnded_BrokerAddressZero(streamId); + } + + // Calculate the broker's amount. + uint128 brokerAmountIn18Decimals = uint128(ud(totalAmount).mul(broker.fee).intoUint256()); + uint128 brokerAmount = _calculateTransferAmount(streamId, brokerAmountIn18Decimals); + + // Checks, Effects and Interactions: deposit on stream. + _deposit({ streamId: streamId, amount: totalAmount - brokerAmountIn18Decimals }); + + // Interaction: transfer the broker's amount. + _streams[streamId].asset.safeTransferFrom(msg.sender, broker.account, brokerAmount); + } + /// @dev Helper function to calculate the transfer amount and to perform the ERC-20 transfer. function _extractFromStream(uint256 streamId, address to, uint128 amount) internal { // Calculate the transfer amount. diff --git a/src/abstracts/SablierV2OpenEndedState.sol b/src/abstracts/SablierV2OpenEndedState.sol index 4e9dbf78..3945a63c 100644 --- a/src/abstracts/SablierV2OpenEndedState.sol +++ b/src/abstracts/SablierV2OpenEndedState.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.22; import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; import { ISablierV2OpenEndedState } from "../interfaces/ISablierV2OpenEndedState.sol"; import { OpenEnded } from "../types/DataTypes.sol"; @@ -20,6 +21,9 @@ abstract contract SablierV2OpenEndedState is STATE VARIABLES //////////////////////////////////////////////////////////////////////////*/ + /// @inheritdoc ISablierV2OpenEndedState + UD60x18 public constant override MAX_BROKER_FEE = UD60x18.wrap(0.1e18); + /// @inheritdoc ISablierV2OpenEndedState uint256 public override nextStreamId; diff --git a/src/interfaces/ISablierV2OpenEnded.sol b/src/interfaces/ISablierV2OpenEnded.sol index 5e67f3a7..e9f9bb01 100644 --- a/src/interfaces/ISablierV2OpenEnded.sol +++ b/src/interfaces/ISablierV2OpenEnded.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ISablierV2OpenEndedState } from "./ISablierV2OpenEndedState.sol"; +import { Broker } from "../types/DataTypes.sol"; /// @title ISablierV2OpenEnded /// @notice Creates and manages Open Ended streams with linear streaming functions. @@ -60,7 +61,7 @@ interface ISablierV2OpenEnded is /// @param streamId The ID of the open-ended stream. /// @param funder The address which funded the stream. /// @param asset The contract address of the ERC-20 asset used for streaming. - /// @param depositAmount The amount of assets deposited, denoted in 18 decimals. + /// @param depositAmount The amount of assets deposited into the stream, denoted in 18 decimals. event DepositOpenEndedStream( uint256 indexed streamId, address indexed funder, IERC20 indexed asset, uint128 depositAmount ); @@ -173,7 +174,7 @@ interface ISablierV2OpenEnded is /// @param streamId The ID of the stream to cancel. function cancel(uint256 streamId) external; - /// @notice Creates a new open-ended stream with the `block.timestamp` as the time reference and with zero balance. + /// @notice Creates a new open-ended stream with `block.timestamp` as `lastTimeUpdate` and set stream balance to 0. /// The stream is wrapped in an ERC-721 NFT. /// /// @dev Emits a {CreateOpenEndedStream} event. @@ -186,8 +187,7 @@ interface ISablierV2OpenEnded is /// - 'asset' must have valid decimals. /// /// @param recipient The address receiving the assets. - /// @param sender The address streaming the assets, with the ability to adjust and cancel the stream. It doesn't - /// have to be the same as `msg.sender`. + /// @param sender The address streaming the assets. It doesn't have to be the same as `msg.sender`. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. /// @param asset The contract address of the ERC-20 asset used for streaming. /// @param isTransferable Boolean indicating if the stream NFT is transferable. @@ -202,18 +202,16 @@ interface ISablierV2OpenEnded is external returns (uint256 streamId); - /// @notice Creates a new open-ended stream with the `block.timestamp` as the time reference - /// and with `amount` balance. The stream is wrapped in an ERC-721 NFT. + /// @notice Creates a new open-ended stream with `block.timestamp` as `lastTimeUpdate` and set the stream balance to + /// `amount`. The stream is wrapped in an ERC-721 NFT. /// /// @dev Emits a {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. /// /// Requirements: - /// - `amount` must be greater than zero. - /// - Refer to the requirements in {create}. + /// - Refer to the requirements in {create} and {deposit}. /// /// @param recipient The address receiving the assets. - /// @param sender The address streaming the assets, with the ability to adjust and cancel the stream. It doesn't - /// have to be the same as `msg.sender`. + /// @param sender The address streaming the assets. It doesn't have to be the same as `msg.sender`. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. /// @param asset The contract address of the ERC-20 asset used for streaming. /// @param isTransferable Boolean indicating if the stream NFT is transferable. @@ -230,6 +228,34 @@ interface ISablierV2OpenEnded is external returns (uint256 streamId); + /// @notice Creates a new open-ended stream with `block.timestamp` as `lastTimeUpdate` and set the stream balance to + /// an amount calculated from the `totalAmount` after broker fee deduction. The stream is wrapped in an ERC-721 NFT. + /// + /// @dev Emits a {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. + /// + /// Requirements: + /// - Refer to the requirements in {create} and {depositViaBroker}. + /// + /// @param recipient The address receiving the assets. + /// @param sender The address streaming the assets. It doesn't have to be the same as `msg.sender`. + /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. + /// @param asset The contract address of the ERC-20 asset used for streaming. + /// @param isTransferable Boolean indicating if the stream NFT is transferable. + /// @param totalAmount The total amount, including the stream deposit and broker fee, both denoted in 18 decimals. + /// @param broker The broker's address and fee. + /// @return streamId The ID of the newly created stream. + function createAndDepositViaBroker( + address recipient, + address sender, + uint128 ratePerSecond, + IERC20 asset, + bool isTransferable, + uint128 totalAmount, + Broker calldata broker + ) + external + returns (uint256 streamId); + /// @notice Deposits assets in a stream. /// /// @dev Emits a {Transfer} and {DepositOpenEndedStream} event. @@ -243,6 +269,22 @@ interface ISablierV2OpenEnded is /// @param amount The amount deposited in the stream, denoted in 18 decimals. function deposit(uint256 streamId, uint128 amount) external; + /// @notice Deposits assets in a stream. + /// + /// @dev Emits a {Transfer} and {DepositOpenEndedStream} event. + /// + /// Requirements: + /// - Must not be delegate called. + /// - `streamId` must not reference a null stream or a canceled stream. + /// - `totalAmount` must be greater than broker amount. + /// - `broker.account` must not be 0 address. + /// - `broker.fee` must not be greater than `MAX_BROKER_FEE`. It can be zero. + /// + /// @param streamId The ID of the stream to deposit on. + /// @param totalAmount The total amount, including the stream deposit and broker fee, both denoted in 18 decimals. + /// @param broker The broker's address and fee. + function depositViaBroker(uint256 streamId, uint128 totalAmount, Broker calldata broker) external; + /// @notice Refunds the provided amount of assets from the stream to the sender's address. /// /// @dev Emits a {Transfer} and {RefundFromOpenEndedStream} event. diff --git a/src/interfaces/ISablierV2OpenEndedState.sol b/src/interfaces/ISablierV2OpenEndedState.sol index 97287d19..a722dc41 100644 --- a/src/interfaces/ISablierV2OpenEndedState.sol +++ b/src/interfaces/ISablierV2OpenEndedState.sol @@ -3,8 +3,9 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; -import { OpenEnded } from "../types/DataTypes.sol"; +import { Broker, OpenEnded } from "../types/DataTypes.sol"; /// @title ISablierV2OpenEndedState /// @notice State variables, storage and constants, for the {SablierV2OpenEnded} contract, and their respective getters. @@ -72,6 +73,11 @@ interface ISablierV2OpenEndedState is /// @param streamId The stream ID for the query. function isStream(uint256 streamId) external view returns (bool result); + /// @notice Retrieves the maximum broker fee that can be charged by the broker, denoted as a fixed-point number + /// where 1e18 is 100%. + /// @dev This value is hard coded as a constant. + function MAX_BROKER_FEE() external view returns (UD60x18 fee); + /// @notice Counter for stream ids. /// @return The next stream id. function nextStreamId() external view returns (uint256); diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index b8d761ca..f995c064 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; /// @title Errors /// @notice Library with custom erros used across the OpenEnded contract. @@ -17,7 +18,13 @@ library Errors { SABLIER-V2-OpenEnded //////////////////////////////////////////////////////////////////////////*/ - /// @notice Thrown when trying to create a OpenEnded stream with a zero deposit amount. + /// @notice Thrown when trying to create a stream with a broker fee more than the allowed. + error SablierV2OpenEnded_BrokerFeeTooHigh(uint256 streamId, UD60x18 fee, UD60x18 maxFee); + + /// @notice Thrown when trying to create a stream with a broker recipient address as zero. + error SablierV2OpenEnded_BrokerAddressZero(uint256 streamId); + + /// @notice Thrown when trying to create a stream with a zero deposit amount. error SablierV2OpenEnded_DepositAmountZero(); /// @notice Thrown when trying to create a stream with an asset with no decimals. @@ -48,7 +55,7 @@ library Errors { /// @notice Thrown when trying to refund zero assets from a stream. error SablierV2OpenEnded_RefundAmountZero(); - /// @notice Thrown when trying to create a OpenEnded stream with the sender as the zero address. + /// @notice Thrown when trying to create a stream with the sender as the zero address. error SablierV2OpenEnded_SenderZeroAddress(); /// @notice Thrown when trying to perform an action with a canceled stream. diff --git a/src/types/DataTypes.sol b/src/types/DataTypes.sol index 926f7d16..62d30cce 100644 --- a/src/types/DataTypes.sol +++ b/src/types/DataTypes.sol @@ -2,8 +2,16 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; -// TODO: add Broker +/// @notice Struct encapsulating the broker parameters passed to the `depositViaBroker` and `createAndDepositViaBroker` +/// functions. +/// @param account The address receiving the broker's fee. +/// @param fee The broker's percentage fee from the amount passed, denoted as a fixed-point number where 1e18 is 100%. +struct Broker { + address account; + UD60x18 fee; +} library OpenEnded { /// @notice OpenEnded stream. diff --git a/test/integration/constructor.t.sol b/test/integration/constructor.t.sol index 52cd0f43..7a85a646 100644 --- a/test/integration/constructor.t.sol +++ b/test/integration/constructor.t.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.22; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; import { SablierV2OpenEnded } from "src/SablierV2OpenEnded.sol"; + import { Integration_Test } from "./Integration.t.sol"; contract Constructor_Integration_Concrete_Test is Integration_Test { @@ -9,6 +11,12 @@ contract Constructor_Integration_Concrete_Test is Integration_Test { // Construct the contract. SablierV2OpenEnded constructedOpenEnded = new SablierV2OpenEnded(); + // {SablierV2OpenEndedState.MAX_BROKER_FEE} + UD60x18 actualMaxBrokerFee = constructedOpenEnded.MAX_BROKER_FEE(); + UD60x18 expectedMaxBrokerFee = UD60x18.wrap(0.1e18); + assertEq(actualMaxBrokerFee, expectedMaxBrokerFee, "MAX_BROKER_FEE"); + + // {SablierV2OpenEndedState.nextStreamId} uint256 actualStreamId = constructedOpenEnded.nextStreamId(); uint256 expectedStreamId = 1; assertEq(actualStreamId, expectedStreamId, "nextStreamId");