diff --git a/script/Base.s.sol b/script/Base.s.sol index a64b9c2b..3b551b38 100644 --- a/script/Base.s.sol +++ b/script/Base.s.sol @@ -1,4 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later +// solhint-disable no-console pragma solidity >=0.8.22; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; @@ -57,7 +58,7 @@ abstract contract BaseScript is Script { string memory json = vm.readFile("package.json"); string memory version = json.readString(".version"); string memory create2Salt = string.concat("ChainID ", chainId, ", Version ", version); - console2.log("The CREATE2 salt is \"%s\"", create2Salt); + console2.log("The CREATE2 salt is %s", create2Salt); return bytes32(abi.encodePacked(create2Salt)); } } diff --git a/src/SablierV2OpenEnded.sol b/src/SablierV2OpenEnded.sol index 6687cb59..67195382 100644 --- a/src/SablierV2OpenEnded.sol +++ b/src/SablierV2OpenEnded.sol @@ -28,6 +28,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope view override notCanceled(streamId) + notNull(streamId) returns (uint128 refundableAmount) { refundableAmount = _refundableAmountOf(streamId, uint40(block.timestamp)); @@ -42,25 +43,40 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope view override notCanceled(streamId) + notNull(streamId) returns (uint128 refundableAmount) { refundableAmount = _refundableAmountOf(streamId, time); } /// @inheritdoc ISablierV2OpenEnded - function streamDebt(uint256 streamId) external view notCanceled(streamId) returns (uint128 debt) { + function streamDebtOf(uint256 streamId) + external + view + override + notCanceled(streamId) + notNull(streamId) + returns (uint128 debt) + { uint128 balance = _streams[streamId].balance; uint128 streamedAmount = _streamedAmountOf(streamId, uint40(block.timestamp)); - if (balance >= streamedAmount) { + if (balance < streamedAmount) { + debt = streamedAmount - balance; + } else { return 0; } - - debt = streamedAmount - balance; } /// @inheritdoc ISablierV2OpenEnded - function streamedAmountOf(uint256 streamId) external view notCanceled(streamId) returns (uint128 streamedAmount) { + function streamedAmountOf(uint256 streamId) + external + view + override + notCanceled(streamId) + notNull(streamId) + returns (uint128 streamedAmount) + { streamedAmount = _streamedAmountOf(streamId, uint40(block.timestamp)); } @@ -71,7 +87,9 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope ) external view + override notCanceled(streamId) + notNull(streamId) returns (uint128 streamedAmount) { streamedAmount = _streamedAmountOf(streamId, time); @@ -81,7 +99,9 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope function withdrawableAmountOf(uint256 streamId) external view + override notCanceled(streamId) + notNull(streamId) returns (uint128 withdrawableAmount) { withdrawableAmount = _withdrawableAmountOf(streamId, uint40(block.timestamp)); @@ -94,7 +114,9 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope ) external view + override notCanceled(streamId) + notNull(streamId) returns (uint128 withdrawableAmount) { withdrawableAmount = _withdrawableAmountOf(streamId, time); @@ -110,8 +132,10 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope uint128 newRatePerSecond ) external + override noDelegateCall notCanceled(streamId) + notNull(streamId) onlySender(streamId) { // Effects and Interactions: adjust the stream. @@ -119,10 +143,27 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope } /// @inheritdoc ISablierV2OpenEnded - function cancel(uint256 streamId) external noDelegateCall notCanceled(streamId) onlySender(streamId) { + function cancel(uint256 streamId) + public + override + noDelegateCall + notCanceled(streamId) + notNull(streamId) + onlySender(streamId) + { _cancel(streamId); } + /// @inheritdoc ISablierV2OpenEnded + function cancelMultiple(uint256[] calldata streamIds) external override { + // Iterate over the provided array of stream IDs and cancel each stream. + uint256 count = streamIds.length; + for (uint256 i = 0; i < count; ++i) { + // Effects and Interactions: cancel the stream. + cancel(streamIds[i]); + } + } + /// @inheritdoc ISablierV2OpenEnded function create( address sender, @@ -131,6 +172,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope IERC20 asset ) external + override returns (uint256 streamId) { // Checks, Effects and Interactions: create the stream. @@ -143,26 +185,92 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope address recipient, uint128 ratePerSecond, IERC20 asset, - uint128 depositAmount + uint128 amount ) external + override returns (uint256 streamId) { // Checks, Effects and Interactions: create the stream. streamId = _create(sender, recipient, ratePerSecond, asset); // Checks, Effects and Interactions: deposit on stream. - _deposit(streamId, depositAmount); + _deposit(streamId, amount); + } + + /// @inheritdoc ISablierV2OpenEnded + function createMultiple( + address[] calldata recipients, + address[] calldata senders, + uint128[] calldata ratesPerSecond, + IERC20 asset + ) + public + override + returns (uint256[] memory streamIds) + { + uint256 recipientsCount = recipients.length; + uint256 sendersCount = senders.length; + uint256 ratesPerSecondCount = ratesPerSecond.length; + + // Check: count of `senders`, `recipients` and `ratesPerSecond` matches. + if (recipientsCount != sendersCount || recipientsCount != ratesPerSecondCount) { + revert Errors.SablierV2OpenEnded_CreateMultipleArrayCountsNotEqual( + recipientsCount, sendersCount, ratesPerSecondCount + ); + } + + streamIds = new uint256[](recipientsCount); + for (uint256 i = 0; i < recipientsCount; ++i) { + // Checks, Effects and Interactions: create the stream. + streamIds[i] = _create(senders[i], recipients[i], ratesPerSecond[i], asset); + } + } + + /// @inheritdoc ISablierV2OpenEnded + function createAndDepositMultiple( + address[] calldata recipients, + address[] calldata senders, + uint128[] calldata ratesPerSecond, + IERC20 asset, + uint128[] calldata amounts + ) + external + override + returns (uint256[] memory streamIds) + { + streamIds = new uint256[](recipients.length); + streamIds = createMultiple(recipients, senders, ratesPerSecond, asset); + + uint256 streamIdsCount = streamIds.length; + if (streamIdsCount != amounts.length) { + revert Errors.SablierV2OpenEnded_DepositArrayCountsNotEqual(streamIdsCount, amounts.length); + } + + // Deposit on each stream. + for (uint256 i = 0; i < streamIdsCount; ++i) { + // Checks, Effects and Interactions: deposit on stream. + _deposit(streamIds[i], amounts[i]); + } } /// @inheritdoc ISablierV2OpenEnded - function deposit(uint256 streamId, uint128 amount) external noDelegateCall notCanceled(streamId) { + function deposit( + uint256 streamId, + uint128 amount + ) + external + override + noDelegateCall + notCanceled(streamId) + notNull(streamId) + { // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); } /// @inheritdoc ISablierV2OpenEnded - function depositMultiple(uint256[] calldata streamIds, uint128[] calldata amounts) external noDelegateCall { + function depositMultiple(uint256[] memory streamIds, uint128[] calldata amounts) public override noDelegateCall { uint256 streamIdsCount = streamIds.length; uint256 amountsCount = amounts.length; @@ -171,36 +279,30 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope revert Errors.SablierV2OpenEnded_DepositArrayCountsNotEqual(streamIdsCount, amountsCount); } - uint256 streamId; - uint128 amount; for (uint256 i = 0; i < streamIdsCount; ++i) { - streamId = streamIds[i]; - // Check: the stream is not canceled. - if (isCanceled(streamId)) { - revert Errors.SablierV2OpenEnded_StreamCanceled(streamId); + if (isCanceled(streamIds[i])) { + revert Errors.SablierV2OpenEnded_StreamCanceled(streamIds[i]); } - amount = amounts[i]; - // Checks, Effects and Interactions: deposit on stream. - _deposit(streamId, amount); + _deposit(streamIds[i], amounts[i]); } } /// @inheritdoc ISablierV2OpenEnded - function restartStream(uint256 streamId, uint128 ratePerSecond) external { + function restartStream(uint256 streamId, uint128 ratePerSecond) external override { // Checks, Effects and Interactions: restart the stream. _restartStream(streamId, ratePerSecond); } /// @inheritdoc ISablierV2OpenEnded - function restartStreamAndDeposit(uint256 streamId, uint128 ratePerSecond, uint128 depositAmount) external { + function restartStreamAndDeposit(uint256 streamId, uint128 ratePerSecond, uint128 amount) external override { // Checks, Effects and Interactions: restart the stream. _restartStream(streamId, ratePerSecond); // Checks, Effects and Interactions: deposit on stream. - _deposit(streamId, depositAmount); + _deposit(streamId, amount); } /// @inheritdoc ISablierV2OpenEnded @@ -209,8 +311,10 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope uint128 amount ) external + override noDelegateCall notCanceled(streamId) + notNull(streamId) onlySender(streamId) { // Checks, Effects and Interactions: make the refund. @@ -218,13 +322,29 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope } /// @inheritdoc ISablierV2OpenEnded - function withdraw(uint256 streamId, address to, uint40 time) external { + function withdraw(uint256 streamId, address to, uint40 time) external override { // Checks, Effects and Interactions: make the withdrawal. _withdraw(streamId, to, time); } /// @inheritdoc ISablierV2OpenEnded - function withdrawMax(uint256 streamId, address to) external { + function withdrawMultiple(uint256[] calldata streamIds, uint40[] calldata times) external override noDelegateCall { + // Check: there is an equal number of `streamIds` and `amounts`. + uint256 streamIdsCount = streamIds.length; + uint256 timesCount = times.length; + if (streamIdsCount != timesCount) { + revert Errors.SablierV2OpenEnded_WithdrawMultipleArrayCountsNotEqual(streamIdsCount, timesCount); + } + + // Iterate over the provided array of stream IDs, and withdraw from each stream to the recipient. + for (uint256 i = 0; i < streamIdsCount; ++i) { + // Checks, Effects and Interactions: check the parameters and make the withdrawal. + _withdraw({ streamId: streamIds[i], to: _streams[streamIds[i]].recipient, time: times[i] }); + } + } + + /// @inheritdoc ISablierV2OpenEnded + function withdrawMax(uint256 streamId, address to) external override { // Checks, Effects and Interactions: make the withdrawal. _withdraw(streamId, to, uint40(block.timestamp)); } @@ -292,7 +412,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope /// @dev Calculates the streamed amount. function _streamedAmountOf(uint256 streamId, uint40 time) internal view returns (uint128) { - uint128 lastTimeUpdate = uint128(_streams[streamId].lastTimeUpdate); + uint40 lastTimeUpdate = _streams[streamId].lastTimeUpdate; // If the time reference is less than or equal to the `lastTimeUpdate`, return zero. if (time <= lastTimeUpdate) { @@ -473,7 +593,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope /// @dev See the documentation for the user-facing functions that call this internal function. function _deposit(uint256 streamId, uint128 amount) internal { - // Check: the amount is not zero. + // Check: the deposit amount is not zero. if (amount == 0) { revert Errors.SablierV2OpenEnded_DepositAmountZero(); } @@ -567,7 +687,16 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope } /// @dev See the documentation for the user-facing functions that call this internal function. - function _withdraw(uint256 streamId, address to, uint40 time) internal noDelegateCall notCanceled(streamId) { + function _withdraw( + uint256 streamId, + address to, + uint40 time + ) + internal + noDelegateCall + notCanceled(streamId) + notNull(streamId) + { // Check: the withdrawal address is not zero. if (to == address(0)) { revert Errors.SablierV2OpenEnded_WithdrawToZeroAddress(); diff --git a/src/abstracts/SablierV2OpenEndedState.sol b/src/abstracts/SablierV2OpenEndedState.sol index 2b0221a6..6464fcf9 100644 --- a/src/abstracts/SablierV2OpenEndedState.sol +++ b/src/abstracts/SablierV2OpenEndedState.sol @@ -34,7 +34,7 @@ abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { /// @dev Checks that `streamId` does not reference a canceled stream. modifier notCanceled(uint256 streamId) { - if (isCanceled(streamId)) { + if (_streams[streamId].isCanceled) { revert Errors.SablierV2OpenEnded_StreamCanceled(streamId); } _; @@ -50,7 +50,7 @@ abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { /// @dev Checks the `msg.sender` is the stream's sender. modifier onlySender(uint256 streamId) { - if (!_isCallerStreamSender(streamId)) { + if (msg.sender != _streams[streamId].sender) { revert Errors.SablierV2OpenEnded_Unauthorized(streamId, msg.sender); } _; @@ -109,12 +109,18 @@ abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { } /// @inheritdoc ISablierV2OpenEndedState - function getSender(uint256 streamId) external view notNull(streamId) returns (address sender) { + function getSender(uint256 streamId) external view override notNull(streamId) returns (address sender) { sender = _streams[streamId].sender; } /// @inheritdoc ISablierV2OpenEndedState - function getStream(uint256 streamId) external view notNull(streamId) returns (OpenEnded.Stream memory stream) { + function getStream(uint256 streamId) + external + view + override + notNull(streamId) + returns (OpenEnded.Stream memory stream) + { stream = _streams[streamId]; } @@ -124,17 +130,7 @@ abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { } /// @inheritdoc ISablierV2OpenEndedState - function isStream(uint256 streamId) public view returns (bool result) { + function isStream(uint256 streamId) public view override returns (bool result) { result = _streams[streamId].isStream; } - - /*////////////////////////////////////////////////////////////////////////// - INTERNAL CONSTANT FUNCTIONS - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Checks whether `msg.sender` is the stream's sender. - /// @param streamId The stream id for the query. - function _isCallerStreamSender(uint256 streamId) internal view returns (bool) { - return msg.sender == _streams[streamId].sender; - } } diff --git a/src/interfaces/ISablierV2OpenEnded.sol b/src/interfaces/ISablierV2OpenEnded.sol index f323e404..924e0830 100644 --- a/src/interfaces/ISablierV2OpenEnded.sol +++ b/src/interfaces/ISablierV2OpenEnded.sol @@ -13,7 +13,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { //////////////////////////////////////////////////////////////////////////*/ /// @notice Emitted when the sender changes the rate per second. - /// @param streamId The id of the stream. + /// @param streamId The ID of the stream. /// @param recipientAmount The amount of assets withdrawn to the recipient, denoted in 18 decimals. /// @param oldRatePerSecond The rate per second to change. /// @param newRatePerSecond The newly changed rate per second. @@ -26,7 +26,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { ); /// @notice Emitted when a open-ended stream is canceled. - /// @param streamId The id of the stream. + /// @param streamId The ID of the stream. /// @param sender The address of the stream's sender. /// @param recipient The address of the stream's recipient. /// @param asset The contract address of the ERC-20 asset used for streaming. @@ -42,7 +42,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { ); /// @notice Emitted when a open-ended stream is created. - /// @param streamId The id of the newly created stream. + /// @param streamId The ID of the newly created stream. /// @param sender The address from which to stream the assets, which has the ability to /// adjust and cancel the stream. /// @param recipient The address toward which to stream the assets. @@ -59,25 +59,25 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { ); /// @notice Emitted when a open-ended stream is funded. - /// @param streamId The id of the open-ended stream. + /// @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 amount The amount of assets deposited, denoted in 18 decimals. + /// @param depositAmount The amount of assets deposited, denoted in 18 decimals. event DepositOpenEndedStream( - uint256 indexed streamId, address indexed funder, IERC20 indexed asset, uint128 amount + uint256 indexed streamId, address indexed funder, IERC20 indexed asset, uint128 depositAmount ); /// @notice Emitted when assets are refunded from a open-ended stream. - /// @param streamId The id of the open-ended stream. + /// @param streamId The ID of the open-ended stream. /// @param sender The address of the stream's sender. /// @param asset The contract address of the ERC-20 asset used for streaming. - /// @param amount The amount of assets deposited, denoted in 18 decimals. + /// @param refundAmount The amount of assets refunded to the sender, denoted in 18 decimals. event RefundFromOpenEndedStream( - uint256 indexed streamId, address indexed sender, IERC20 indexed asset, uint128 amount + uint256 indexed streamId, address indexed sender, IERC20 indexed asset, uint128 refundAmount ); /// @notice Emitted when a open-ended stream is re-started. - /// @param streamId The id of the open-ended stream. + /// @param streamId The ID of the open-ended stream. /// @param sender The address of the stream's sender. /// @param asset The contract address of the ERC-20 asset used for streaming. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. @@ -86,12 +86,12 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { ); /// @notice Emitted when assets are withdrawn from a open-ended stream. - /// @param streamId The id of the stream. + /// @param streamId The ID of the stream. /// @param to The address that has received the withdrawn assets. /// @param asset The contract address of the ERC-20 asset used for streaming. - /// @param amount The amount of assets withdrawn, denoted in 18 decimals. + /// @param withdrawAmount The amount of assets withdrawn, denoted in 18 decimals. event WithdrawFromOpenEndedStream( - uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 amount + uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 withdrawAmount ); /*////////////////////////////////////////////////////////////////////////// @@ -99,43 +99,45 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { //////////////////////////////////////////////////////////////////////////*/ /// @notice Calculates the amount that the sender can refund from stream, denoted in 18 decimals. - /// @dev Reverts if `streamId` references a canceled stream. - /// @param streamId The stream id for the query. + /// @dev Reverts if `streamId` references a canceled or a null stream. + /// @param streamId The stream ID for the query. + /// @return refundableAmount The amount that the sender can refund. function refundableAmountOf(uint256 streamId) external view returns (uint128 refundableAmount); /// @notice Calculates the amount that the sender can refund from stream at `time`, denoted in 18 decimals. - /// @dev Reverts if `streamId` references a canceled stream. - /// @param streamId The stream id for the query. + /// @dev Reverts if `streamId` references a canceled or a null stream. + /// @param streamId The stream ID for the query. /// @param time The Unix timestamp for the streamed amount calculation. + /// @return refundableAmount The amount that the sender can refund. function refundableAmountOf(uint256 streamId, uint40 time) external view returns (uint128 refundableAmount); /// @notice Calculates the amount that the sender owes on the stream, i.e. if more assets have been streamed than /// its balance, denoted in 18 decimals. If there is no debt, it will return zero. - /// @dev Reverts if `streamId` references a canceled stream. - /// @param streamId The stream id for the query. - function streamDebt(uint256 streamId) external view returns (uint128 debt); + /// @dev Reverts if `streamId` references a canceled or a null stream. + /// @param streamId The stream ID for the query. + function streamDebtOf(uint256 streamId) external view returns (uint128 debt); /// @notice Calculates the amount streamed to the recipient from the last time update to the current time, /// denoted in 18 decimals. - /// @dev Reverts if `streamId` references a canceled stream. - /// @param streamId The stream id for the query. + /// @dev Reverts if `streamId` references a canceled or a null stream. + /// @param streamId The stream ID for the query. function streamedAmountOf(uint256 streamId) external view returns (uint128 streamedAmount); /// @notice Calculates the amount streamed to the recipient from the last time update to `time` passed as parameter, /// denoted in 18 decimals. - /// @dev Reverts if `streamId` references a canceled stream. - /// @param streamId The stream id for the query. + /// @dev Reverts if `streamId` references a canceled or a null stream. + /// @param streamId The stream ID for the query. /// @param time The Unix timestamp for the streamed amount calculation. function streamedAmountOf(uint256 streamId, uint40 time) external view returns (uint128 streamedAmount); /// @notice Calculates the amount that the recipient can withdraw from the stream, denoted in 18 decimals. - /// @dev Reverts if `streamId` references a canceled stream. - /// @param streamId The stream id for the query. + /// @dev Reverts if `streamId` references a canceled or a null stream. + /// @param streamId The stream ID for the query. function withdrawableAmountOf(uint256 streamId) external view returns (uint128 withdrawableAmount); /// @notice Calculates the amount that the recipient can withdraw from the stream at `time`, denoted in 18 decimals. - /// @dev Reverts if `streamId` references a canceled stream. - /// @param streamId The stream id for the query. + /// @dev Reverts if `streamId` references a canceled or a null stream. + /// @param streamId The stream ID for the query. /// @param time The Unix timestamp for the streamed amount calculation. function withdrawableAmountOf(uint256 streamId, uint40 time) external view returns (uint128 withdrawableAmount); @@ -149,32 +151,40 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// /// Notes: /// - The streamed assets, until the adjustment moment, must be transferred to the recipient. + /// - This function updates stream's `lastTimeUpdate` to the current block timestamp. /// /// Requiremenets: /// - Must not be delegate called. - /// - `streamId` must not reference a null stream. - /// - `streamId` must not reference a canceled stream. + /// - `streamId` must not reference a null stream or a canceled stream. /// - `msg.sender` must be the stream's sender. - /// - `newRatePerSecond` must be greater than zero. - /// - `newRatePerSecond` must not be equal to the actual rate per second. + /// - `newRatePerSecond` must be greater than zero and not equal to the current rate per second. /// - /// @param streamId The id of the stream to adjust. + /// @param streamId The ID of the stream to adjust. /// @param newRatePerSecond The new rate per second of the open-ended stream, denoted in 18 decimals. function adjustRatePerSecond(uint256 streamId, uint128 newRatePerSecond) external; - /// @notice Cancels the stream and refunds any remaining assets to the sender. + /// @notice Cancels the stream and refunds available assets to the sender and recipient. /// /// @dev Emits a {Transfer} and {CancelOpenEndedStream} event. /// /// Requirements: /// - Must not be delegate called. - /// - `streamId` must not reference a null stream. - /// - `streamId` must not reference a canceled stream. + /// - `streamId` must not reference a null stream or a canceled stream. /// - `msg.sender` must be the stream's sender. /// - /// @param streamId The id of the stream to cancel. + /// @param streamId The ID of the stream to cancel. function cancel(uint256 streamId) external; + /// @notice Cancels multiple streams and refunds available assets to the sender and to the recipient of each stream. + /// + /// @dev Emits multiple {Transfer} and {CancelOpenEndedStream} events. + /// + /// Requirements: + /// - All requirements from {cancel} must be met for each stream. + /// + /// @param streamIds The IDs of the streams to cancel. + function cancelMultiple(uint256[] calldata streamIds) external; + /// @notice Creates a new open-ended stream with the `block.timestamp` as the time reference and with zero balance. /// /// @dev Emits a {CreateOpenEndedStream} event. @@ -188,11 +198,10 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// /// @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`. + /// 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. - /// @return streamId The id of the newly created stream. + /// @return streamId The ID of the newly created stream. function create( address recipient, address sender, @@ -203,12 +212,12 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { returns (uint256 streamId); /// @notice Creates a new open-ended stream with the `block.timestamp` as the time reference - /// and with `depositAmount` balance. + /// and with `amount` balance. /// /// @dev Emits a {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. /// /// Requirements: - /// - `depositAmount` must be greater than zero. + /// - `amount` must be greater than zero. /// - Refer to the requirements in {create}. /// /// @param recipient The address receiving the assets. @@ -216,29 +225,75 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// 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 depositAmount The amount deposited in the stream. - /// @return streamId The id of the newly created stream. + /// @param amount The amount deposited in the stream. + /// @return streamId The ID of the newly created stream. function createAndDeposit( address recipient, address sender, uint128 ratePerSecond, IERC20 asset, - uint128 depositAmount + uint128 amount ) external returns (uint256 streamId); + /// @notice Creates multiple open-ended streams with the `block.timestamp` as the time reference and with + /// `amounts` balances. + /// + /// @dev Emits multiple {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. + /// + /// Requirements: + /// - All requirements from {create} must be met for each stream. + /// - `recipients`, `senders`, `ratesPerSecond` and `amounts` arrays must be of equal length. + /// + /// @param recipients The addresses receiving the assets. + /// @param senders The addresses streaming the assets, with the ability to adjust and cancel the stream. + /// @param ratesPerSecond The amounts of assets that are increasing by every second, denoted in 18 decimals. + /// @param asset The contract address of the ERC-20 asset used for streaming. + /// @param amounts The amounts deposited in the streams. + /// @return streamIds The IDs of the newly created streams. + function createAndDepositMultiple( + address[] calldata recipients, + address[] calldata senders, + uint128[] calldata ratesPerSecond, + IERC20 asset, + uint128[] calldata amounts + ) + external + returns (uint256[] memory streamIds); + + /// @notice Creates multiple open-ended streams with the `block.timestamp` as the time reference and with zero + /// balance. + /// + /// @dev Emits multiple {CreateOpenEndedStream} events. + /// + /// Requirements: + /// - `recipients`, `senders` and `ratesPerSecond` arrays must be of equal length. + /// - All requirements from {create} must be met for each stream. + /// + /// @param recipients The addresses receiving the assets. + /// @param senders The addresses streaming the assets, with the ability to adjust and cancel the stream. + /// @param ratesPerSecond The amounts of assets that are increasing by every second, denoted in 18 decimals. + /// @param asset The contract address of the ERC-20 asset used for streaming. + function createMultiple( + address[] calldata recipients, + address[] calldata senders, + uint128[] calldata ratesPerSecond, + IERC20 asset + ) + external + returns (uint256[] memory streamIds); + /// @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. - /// - `streamId` must not reference a canceled stream. + /// - `streamId` must not reference a null stream or a canceled stream. /// - `amount` must be greater than zero. /// - /// @param streamId The id of the stream to deposit on. + /// @param streamId The ID of the stream to deposit on. /// @param amount The amount deposited in the stream, denoted in 18 decimals. function deposit(uint256 streamId, uint128 amount) external; @@ -248,10 +303,10 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// /// Requirements: /// - All requirements from {deposit} must be met for each stream. - /// - There must be an equal number of `streamIds` and `amounts`. + /// - `streamIds` and `amounts` arrays must be of equal length. /// /// @param streamIds The ids of the streams to deposit on. - /// @param amounts The amounts of assets to be deposited, denoted in 18 decimals. + /// @param amounts The amount of assets to be deposited, denoted in 18 decimals. function depositMultiple(uint256[] calldata streamIds, uint128[] calldata amounts) external; /// @notice Refunds the provided amount of assets from the stream to the sender's address. @@ -260,43 +315,42 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// /// Requirements: /// - Must not be delegate called. - /// - `streamId` must not reference a null stream. - /// - `streamId` must not reference a canceled stream. + /// - `streamId` must not reference a null stream or a canceled stream. /// - `msg.sender` must be the sender. /// - `amount` must be greater than zero and must not exceed the refundable amount. /// - /// @param streamId The id of the stream to refund from. - /// @param amount The amount to refund, in units of the ERC-20 asset's decimals. + /// @param streamId The ID of the stream to refund from. + /// @param amount The amount to refund, denoted in 18 decimals. function refundFromStream(uint256 streamId, uint128 amount) external; /// @notice Restarts the stream with the provided rate per second. /// /// @dev Emits a {RestartOpenEndedStream} event. + /// - This function updates stream's `lastTimeUpdate` to the current block timestamp. /// /// Requirements: /// - Must not be delegate called. - // - `streamId` must not reference a null stream. - /// - `streamId` must reference a canceled stream. + // - `streamId` must not reference a null stream or a canceled stream. /// - `msg.sender` must be the stream's sender. /// - `ratePerSecond` must be greater than zero. /// - /// @param streamId The id of the stream to restart. + /// @param streamId The ID of the stream to restart. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. function restartStream(uint256 streamId, uint128 ratePerSecond) external; - /// @notice Restarts the stream with the provided rate per second, and deposits `depositAmount` in the stream + /// @notice Restarts the stream with the provided rate per second, and deposits `amount` in the stream /// balance. /// /// @dev Emits a {RestartOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} event. /// /// Requirements: - /// - `depositAmount` must be greater than zero. + /// - `amount` must be greater than zero. /// - Refer to the requirements in {restartStream}. /// - /// @param streamId The id of the stream to restart. + /// @param streamId The ID of the stream to restart. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. - /// @param depositAmount The amount deposited in the stream. - function restartStreamAndDeposit(uint256 streamId, uint128 ratePerSecond, uint128 depositAmount) external; + /// @param amount The amount deposited in the stream. + function restartStreamAndDeposit(uint256 streamId, uint128 ratePerSecond, uint128 amount) external; /// @notice Withdraws the amount of assets calculated based on time reference, from the stream /// to the provided `to` address. @@ -311,7 +365,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// - `time` must be greater than the stream's `lastTimeUpdate` and must not be in the future. /// - The stream balance must be greater than zero. /// - /// @param streamId The id of the stream to withdraw from. + /// @param streamId The ID of the stream to withdraw from. /// @param to The address receiving the withdrawn assets. /// @param time The Unix timestamp for the streamed amount calculation. function withdraw(uint256 streamId, address to, uint40 time) external; @@ -323,7 +377,21 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// Requirements: /// - Refer to the requirements in {withdraw}. /// - /// @param streamId The id of the stream to withdraw from. + /// @param streamId The ID of the stream to withdraw from. /// @param to The address receiving the withdrawn assets. function withdrawMax(uint256 streamId, address to) external; + + /// @notice Withdraws assets from streams to the recipient of each stream. + /// + /// @dev Emits multiple {Transfer} and {WithdrawFromOpenEndedStream} events. + /// + /// Requirements: + /// - Must not be delegate called. + /// - `streamIds` and `times` arrays must be of equal length. + /// - Each stream ID in the array must not reference a null stream. + /// - Each time in the array must be greater than the last time update and must not exceed `block.timestamp`. + /// + /// @param streamIds The IDs of the streams to withdraw from. + /// @param times The time references to calculate the streamed amount for each stream. + function withdrawMultiple(uint256[] calldata streamIds, uint40[] calldata times) external; } diff --git a/src/interfaces/ISablierV2OpenEndedState.sol b/src/interfaces/ISablierV2OpenEndedState.sol index f17e6df1..dd60c3e5 100644 --- a/src/interfaces/ISablierV2OpenEndedState.sol +++ b/src/interfaces/ISablierV2OpenEndedState.sol @@ -15,53 +15,53 @@ interface ISablierV2OpenEndedState { /// @notice Retrieves the asset of the stream. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The id of the stream to make the query for. + /// @param streamId The ID of the stream to make the query for. function getAsset(uint256 streamId) external view returns (IERC20 asset); /// @notice Retrieves the asset decimals of the stream. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The id of the stream to make the query for. + /// @param streamId The ID of the stream to make the query for. function getAssetDecimals(uint256 streamId) external view returns (uint8 assetDecimals); /// @notice Retrieves the balance of the stream, i.e. the total deposited amounts subtracted by the total withdrawn /// amounts, denoted in 18 decimals. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The stream id for the query. + /// @param streamId The stream ID for the query. function getBalance(uint256 streamId) external view returns (uint128 balance); /// @notice Retrieves the last time update of the stream, which is a Unix timestamp. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The id of the stream to make the query for. + /// @param streamId The ID of the stream to make the query for. function getLastTimeUpdate(uint256 streamId) external view returns (uint40 lastTimeUpdate); /// @notice Retrieves the rate per second of the stream, denoted in 18 decimals. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The id of the stream to make the query for. + /// @param streamId The ID of the stream to make the query for. function getRatePerSecond(uint256 streamId) external view returns (uint128 ratePerSecond); /// @notice Retrieves the stream's recipient. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The stream id for the query. + /// @param streamId The stream ID for the query. function getRecipient(uint256 streamId) external view returns (address recipient); /// @notice Retrieves the stream's sender. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The stream id for the query. + /// @param streamId The stream ID for the query. function getSender(uint256 streamId) external view returns (address sender); /// @notice Retrieves the stream entity. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The stream id for the query. + /// @param streamId The stream ID for the query. function getStream(uint256 streamId) external view returns (OpenEnded.Stream memory stream); /// @notice Retrieves a flag indicating whether the stream is canceled. /// @dev Reverts if `streamId` references a null stream. - /// @param streamId The stream id for the query. + /// @param streamId The stream ID for the query. function isCanceled(uint256 streamId) external view returns (bool result); /// @notice Retrieves a flag indicating whether the stream exists. /// @dev Does not revert if `streamId` references a null stream. - /// @param streamId The stream id for the query. + /// @param streamId The stream ID for the query. function isStream(uint256 streamId) external view returns (bool result); /// @notice Counter for stream ids. diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index c86ff401..a6cdde02 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -17,18 +17,18 @@ library Errors { SABLIER-V2-OpenEnded //////////////////////////////////////////////////////////////////////////*/ - /// @notice Thrown when trying to set the rate per second of a stream to zero. - error SablierV2OpenEnded_RatePerSecondZero(); - - /// @notice Thrown when trying to change the rate per second with the same rate per second. - error SablierV2OpenEnded_RatePerSecondNotDifferent(uint128 ratePerSecond); + /// @notice Thrown when trying to create multiple streams and the number of senders, recipients and rates per second + /// does not match. + error SablierV2OpenEnded_CreateMultipleArrayCountsNotEqual( + uint256 recipientsCount, uint256 sendersCount, uint256 ratesPerSecondCount + ); /// @notice Thrown when trying to create a OpenEnded stream with a zero deposit amount. error SablierV2OpenEnded_DepositAmountZero(); - /// @notice Thrown when trying to deposit on multiple streams and the number of stream ids does + /// @notice Thrown when trying to deposit on multiple streams and the number of stream IDs does /// not match the number of deposit amounts. - error SablierV2OpenEnded_DepositArrayCountsNotEqual(uint256 streamIdsCount, uint256 amountsCount); + error SablierV2OpenEnded_DepositArrayCountsNotEqual(uint256 streamIdsCount, uint256 depositAmountsCount); /// @notice Thrown when trying to create a stream with an asset with no decimals. error SablierV2OpenEnded_InvalidAssetDecimals(IERC20 asset); @@ -36,11 +36,17 @@ library Errors { /// @notice Thrown when an unexpected error occurs during the calculation of an amount. error SablierV2OpenEnded_InvalidCalculation(uint256 streamId, uint128 balance, uint128 amount); - /// @notice Thrown when the id references a null stream. + /// @notice Thrown when the ID references a null stream. error SablierV2OpenEnded_Null(uint256 streamId); /// @notice Thrown when trying to refund an amount greater than the refundable amount. - error SablierV2OpenEnded_Overrefund(uint256 streamId, uint128 amount, uint128 refundableAmount); + error SablierV2OpenEnded_Overrefund(uint256 streamId, uint128 refundAmount, uint128 refundableAmount); + + /// @notice Thrown when trying to change the rate per second with the same rate per second. + error SablierV2OpenEnded_RatePerSecondNotDifferent(uint128 ratePerSecond); + + /// @notice Thrown when trying to set the rate per second of a stream to zero. + error SablierV2OpenEnded_RatePerSecondZero(); /// @notice Thrown when trying to create a OpenEnded stream with the recipient as the zero address. error SablierV2OpenEnded_RecipientZeroAddress(); @@ -69,9 +75,13 @@ library Errors { /// @notice Thrown when trying to withdraw assets with a withdrawal time not greater than `lastTimeUpdate`. error SablierV2OpenEnded_WithdrawalTimeNotGreaterThanLastUpdate(uint40 time, uint40 lastUpdate); - /// @notice Thrown when trying to withdraw to the zero address. - error SablierV2OpenEnded_WithdrawToZeroAddress(); - /// @notice Thrown when trying to withdraw but the stream balance is zero. error SablierV2OpenEnded_WithdrawBalanceZero(uint256 streamId); + + /// @notice Thrown when trying to withdraw from multiple streams and the number of stream IDs does + /// not match the number of withdraw times. + error SablierV2OpenEnded_WithdrawMultipleArrayCountsNotEqual(uint256 streamIdCount, uint256 timesCount); + + /// @notice Thrown when trying to withdraw to the zero address. + error SablierV2OpenEnded_WithdrawToZeroAddress(); } diff --git a/test/Base.t.sol b/test/Base.t.sol index 0e7d42ae..710ef3e7 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -17,6 +17,7 @@ import { Utils } from "./utils/Utils.sol"; struct Users { address sender; address recipient; + address eve; } abstract contract Base_Test is Assertions, Events, Modifiers, Test, Utils { @@ -28,7 +29,8 @@ abstract contract Base_Test is Assertions, Events, Modifiers, Test, Utils { uint128 public constant RATE_PER_SECOND = 0.001e18; // 86.4 daily uint128 public constant DEPOSIT_AMOUNT = 50_000e18; - uint40 public immutable ONE_MONTH = 1 days * 30; // "30/360" convention + uint40 internal constant MAY_1_2024 = 1_714_518_000; + uint40 public immutable ONE_MONTH = 30 days; // "30/360" convention uint128 public constant ONE_MONTH_STREAMED_AMOUNT = 2592e18; // 86.4 * 30 uint128 public constant ONE_MONTH_REFUNDABLE_AMOUNT = DEPOSIT_AMOUNT - ONE_MONTH_STREAMED_AMOUNT; uint128 public constant REFUND_AMOUNT = 10_000e18; @@ -51,6 +53,9 @@ abstract contract Base_Test is Assertions, Events, Modifiers, Test, Utils { //////////////////////////////////////////////////////////////////////////*/ constructor() { + // Warp to May 1, 2024 at 00:00 GMT to provide a more realistic testing environment. + vm.warp({ newTimestamp: MAY_1_2024 }); + WARP_ONE_MONTH = uint40(block.timestamp + ONE_MONTH); WITHDRAW_TIME = uint40(block.timestamp) + 2_500_000; } @@ -64,6 +69,7 @@ abstract contract Base_Test is Assertions, Events, Modifiers, Test, Utils { users.sender = createUser("sender"); users.recipient = createUser("recipient"); + users.eve = createUser("eve"); labelConctracts(); diff --git a/test/integration/Integration.t.sol b/test/integration/Integration.t.sol index a65f4e2a..58e88a55 100644 --- a/test/integration/Integration.t.sol +++ b/test/integration/Integration.t.sol @@ -8,12 +8,28 @@ import { Errors } from "src/libraries/Errors.sol"; import { Base_Test } from "../Base.t.sol"; abstract contract Integration_Test is Base_Test { + uint128[] internal defaultDepositAmounts; + uint128[] internal defaultRatesPerSecond; + address[] internal defaultRecipients; + address[] internal defaultSenders; uint256 internal defaultStreamId; + uint256[] internal defaultStreamIds; + uint256 internal nullStreamId = 420; function setUp() public virtual override { Base_Test.setUp(); defaultStreamId = createDefaultStream(); + + defaultStreamIds.push(defaultStreamId); + defaultStreamIds.push(createDefaultStream()); + + for (uint256 i; i < 2; ++i) { + defaultRecipients.push(users.recipient); + defaultSenders.push(users.sender); + defaultRatesPerSecond.push(RATE_PER_SECOND); + defaultDepositAmounts.push(DEPOSIT_AMOUNT); + } } /*////////////////////////////////////////////////////////////////////////// @@ -34,26 +50,28 @@ abstract contract Integration_Test is Base_Test { } function defaultDeposit() internal { - openEnded.deposit(defaultStreamId, DEPOSIT_AMOUNT); + defaultDeposit(defaultStreamId); + } + + function defaultDeposit(uint256 streamId) internal { + openEnded.deposit(streamId, DEPOSIT_AMOUNT); } /*////////////////////////////////////////////////////////////////////////// - COMMON-TESTS + COMMON //////////////////////////////////////////////////////////////////////////*/ - function _test_RevertWhen_DelegateCall(bytes memory callData) internal { + function expectRevertDueToDelegateCall(bytes memory callData) internal { (bool success, bytes memory returnData) = address(openEnded).delegatecall(callData); assertFalse(success, "delegatecall success"); assertEq(returnData, abi.encodeWithSelector(Errors.DelegateCall.selector), "delegatecall return data"); } - uint256 internal nullStreamId = 420; - - function _test_RevertGiven_Null() internal { + function expectRevertNull() internal { vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_Null.selector, nullStreamId)); } - function _test_RevertGiven_Canceled() internal whenNotDelegateCalled givenNotNull { + function expectRevertCanceled() internal { openEnded.cancel(defaultStreamId); vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_StreamCanceled.selector, defaultStreamId)); } diff --git a/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol b/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol index 9860030f..9baaf77e 100644 --- a/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol +++ b/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol @@ -16,16 +16,16 @@ contract adjustRatePerSecond_Integration_Test is Integration_Test { function test_RevertWhen_DelegateCall() external { bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.adjustRatePerSecond, (defaultStreamId, RATE_PER_SECOND)); - _test_RevertWhen_DelegateCall(callData); + expectRevertDueToDelegateCall(callData); } function test_RevertGiven_Null() external whenNotDelegateCalled { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.adjustRatePerSecond({ streamId: nullStreamId, newRatePerSecond: RATE_PER_SECOND }); } function test_RevertGiven_Canceled() external whenNotDelegateCalled givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.adjustRatePerSecond({ streamId: defaultStreamId, newRatePerSecond: RATE_PER_SECOND }); } @@ -43,19 +43,16 @@ contract adjustRatePerSecond_Integration_Test is Integration_Test { openEnded.adjustRatePerSecond({ streamId: defaultStreamId, newRatePerSecond: RATE_PER_SECOND }); } - function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty(address maliciousThirdParty) + function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty() external whenNotDelegateCalled givenNotNull givenNotCanceled whenCallerUnauthorized { - vm.assume(maliciousThirdParty != users.sender && maliciousThirdParty != users.recipient); - resetPrank({ msgSender: maliciousThirdParty }); + resetPrank({ msgSender: users.eve }); vm.expectRevert( - abi.encodeWithSelector( - Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, maliciousThirdParty - ) + abi.encodeWithSelector(Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, users.eve) ); openEnded.adjustRatePerSecond({ streamId: defaultStreamId, newRatePerSecond: RATE_PER_SECOND }); } diff --git a/test/integration/cancel-multiple/cancelMultiple.t.sol b/test/integration/cancel-multiple/cancelMultiple.t.sol new file mode 100644 index 00000000..743ec0bd --- /dev/null +++ b/test/integration/cancel-multiple/cancelMultiple.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { Errors } from "src/libraries/Errors.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract CancelMultiple_Integration_Concrete_Test is Integration_Test { + function setUp() public override { + Integration_Test.setUp(); + + vm.warp({ newTimestamp: WARP_ONE_MONTH }); + } + + function test_CancelMultiple_ArrayCountZero() external whenNotDelegateCalled { + uint256[] memory streamIds = new uint256[](0); + openEnded.cancelMultiple(streamIds); + } + + function test_RevertGiven_OnlyNull() external whenNotDelegateCalled whenArrayCountNotZero { + defaultStreamIds[0] = nullStreamId; + defaultStreamIds[1] = nullStreamId; + vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_Null.selector, nullStreamId)); + openEnded.cancelMultiple({ streamIds: defaultStreamIds }); + } + + function test_RevertGiven_SomeNull() external whenNotDelegateCalled whenArrayCountNotZero { + defaultStreamIds[0] = nullStreamId; + vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_Null.selector, nullStreamId)); + openEnded.cancelMultiple({ streamIds: defaultStreamIds }); + } + + function test_RevertWhen_CallerUnauthorizedAllStreams_MaliciousThirdParty() + external + whenNotDelegateCalled + whenArrayCountNotZero + givenNotNull + whenCallerUnauthorized + { + resetPrank({ msgSender: users.eve }); + + vm.expectRevert( + abi.encodeWithSelector(Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamIds[0], users.eve) + ); + openEnded.cancelMultiple(defaultStreamIds); + } + + function test_RevertWhen_CallerUnauthorizedAllStreams_Recipient() + external + whenNotDelegateCalled + whenArrayCountNotZero + givenNotNull + whenCallerUnauthorized + { + resetPrank({ msgSender: users.recipient }); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamIds[0], users.recipient + ) + ); + openEnded.cancelMultiple(defaultStreamIds); + } + + function test_RevertWhen_CallerUnauthorizedSomeStreams_MaliciousThirdParty() + external + whenNotDelegateCalled + whenArrayCountNotZero + givenNotNull + whenCallerUnauthorized + { + uint256 eveStreamId = openEnded.create({ + sender: users.eve, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai + }); + + resetPrank({ msgSender: users.eve }); + defaultStreamIds[0] = eveStreamId; + vm.expectRevert( + abi.encodeWithSelector(Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamIds[1], users.eve) + ); + openEnded.cancelMultiple(defaultStreamIds); + } + + function test_RevertWhen_CallerUnauthorizedSomeStreams_Recipient() + external + whenNotDelegateCalled + whenArrayCountNotZero + givenNotNull + whenCallerUnauthorized + { + defaultStreamIds[0] = openEnded.create({ + sender: users.recipient, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai + }); + + resetPrank({ msgSender: users.recipient }); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamIds[1], users.recipient + ) + ); + openEnded.cancelMultiple(defaultStreamIds); + } + + function test_CancelMultiple() + external + whenNotDelegateCalled + whenArrayCountNotZero + givenNotNull + whenCallerUnauthorized + { + openEnded.cancelMultiple(defaultStreamIds); + + assertTrue(openEnded.isCanceled(defaultStreamIds[0])); + assertTrue(openEnded.isCanceled(defaultStreamIds[1])); + + assertEq(openEnded.getRatePerSecond(defaultStreamIds[0]), 0); + assertEq(openEnded.getRatePerSecond(defaultStreamIds[1]), 0); + } +} diff --git a/test/integration/cancel-multiple/cancelMultiple.tree b/test/integration/cancel-multiple/cancelMultiple.tree new file mode 100644 index 00000000..7ccdbccb --- /dev/null +++ b/test/integration/cancel-multiple/cancelMultiple.tree @@ -0,0 +1,23 @@ +cancelMultiple.t.sol +├── when the array count is zero +│ └── it should do nothing +└── when the array count is not zero + ├── given the stream IDs array references only null streams + │ └── it should revert + ├── given the stream IDs array references some null streams + │ └── it should revert + └── given the stream IDs array references only streams that are not null + ├── when the caller is unauthorized for all streams + │ ├── when the caller is a malicious third party + │ │ └── it should revert + │ └── when the caller is the recipient + │ └── it should revert + ├── when the caller is unauthorized for some streams + │ ├── when the caller is a malicious third party + │ │ └── it should revert + │ └── when the caller is the recipient + │ └── it should revert + └── when the caller is authorized for all streams + ├── it should cancel the streams + ├── it should update the rate per second for each stream + └── it should emit {CancelOpenEndedStream} events diff --git a/test/integration/cancel/cancel.t.sol b/test/integration/cancel/cancel.t.sol index 3723d8e4..bc209e22 100644 --- a/test/integration/cancel/cancel.t.sol +++ b/test/integration/cancel/cancel.t.sol @@ -17,16 +17,16 @@ contract Cancel_Integration_Test is Integration_Test { function test_RevertWhen_DelegateCall() external { bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.cancel, (defaultStreamId)); - _test_RevertWhen_DelegateCall(callData); + expectRevertDueToDelegateCall(callData); } function test_RevertGiven_Null() external whenNotDelegateCalled { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.cancel(nullStreamId); } function test_RevertGiven_Canceled() external whenNotDelegateCalled givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.cancel(defaultStreamId); } @@ -44,19 +44,16 @@ contract Cancel_Integration_Test is Integration_Test { openEnded.cancel(defaultStreamId); } - function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty(address maliciousThirdParty) + function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty() external whenNotDelegateCalled givenNotNull givenNotCanceled whenCallerUnauthorized { - vm.assume(maliciousThirdParty != users.sender && maliciousThirdParty != users.recipient); - resetPrank({ msgSender: maliciousThirdParty }); + resetPrank({ msgSender: users.eve }); vm.expectRevert( - abi.encodeWithSelector( - Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, maliciousThirdParty - ) + abi.encodeWithSelector(Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, users.eve) ); openEnded.cancel(defaultStreamId); } diff --git a/test/integration/create-and-deposit-multiple/createAndDepositMultiple.t.sol b/test/integration/create-and-deposit-multiple/createAndDepositMultiple.t.sol new file mode 100644 index 00000000..215ec324 --- /dev/null +++ b/test/integration/create-and-deposit-multiple/createAndDepositMultiple.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { Errors } from "src/libraries/Errors.sol"; +import { OpenEnded } from "src/types/DataTypes.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract CreateAndDepositMultiple_Integration_Test is Integration_Test { + function setUp() public override { + Integration_Test.setUp(); + } + + function test_RevertWhen_DepositAmountsArrayIsNotEqual() external whenNotDelegateCalled whenArrayCountsNotEqual { + uint128[] memory depositAmounts = new uint128[](0); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_DepositArrayCountsNotEqual.selector, + defaultRecipients.length, + depositAmounts.length + ) + ); + openEnded.createAndDepositMultiple( + defaultRecipients, defaultSenders, defaultRatesPerSecond, dai, depositAmounts + ); + } + + function test_CreateAndDepositMultiple() external whenNotDelegateCalled whenArrayCountsEqual { + uint256 beforeNextStreamId = openEnded.nextStreamId(); + + vm.expectEmit({ emitter: address(openEnded) }); + emit CreateOpenEndedStream({ + streamId: beforeNextStreamId, + sender: users.sender, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + lastTimeUpdate: uint40(block.timestamp) + }); + vm.expectEmit({ emitter: address(openEnded) }); + emit CreateOpenEndedStream({ + streamId: beforeNextStreamId + 1, + sender: users.sender, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + lastTimeUpdate: uint40(block.timestamp) + }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit DepositOpenEndedStream({ + streamId: beforeNextStreamId, + funder: users.sender, + asset: dai, + depositAmount: DEPOSIT_AMOUNT + }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit DepositOpenEndedStream({ + streamId: beforeNextStreamId + 1, + funder: users.sender, + asset: dai, + depositAmount: DEPOSIT_AMOUNT + }); + + expectCallToTransferFrom({ asset: dai, from: users.sender, to: address(openEnded), amount: DEPOSIT_AMOUNT }); + expectCallToTransferFrom({ asset: dai, from: users.sender, to: address(openEnded), amount: DEPOSIT_AMOUNT }); + + uint256[] memory streamIds = openEnded.createAndDepositMultiple( + defaultRecipients, defaultSenders, defaultRatesPerSecond, dai, defaultDepositAmounts + ); + + uint256 afterNextStreamId = openEnded.nextStreamId(); + + assertEq(streamIds[0], beforeNextStreamId, "streamIds[0] != beforeNextStreamId"); + assertEq(streamIds[1], beforeNextStreamId + 1, "streamIds[1] != beforeNextStreamId + 1"); + + assertEq(streamIds.length, defaultRecipients.length, "streamIds.length != defaultRecipients.length"); + assertEq( + beforeNextStreamId + defaultRecipients.length, + afterNextStreamId, + "afterNextStreamId != beforeNextStreamId + defaultRecipients.length" + ); + + OpenEnded.Stream memory expectedStream = OpenEnded.Stream({ + ratePerSecond: RATE_PER_SECOND, + asset: dai, + assetDecimals: 18, + balance: DEPOSIT_AMOUNT, + lastTimeUpdate: uint40(block.timestamp), + isCanceled: false, + isStream: true, + recipient: users.recipient, + sender: users.sender + }); + + OpenEnded.Stream memory actualStream = openEnded.getStream(streamIds[0]); + assertEq(actualStream, expectedStream); + + actualStream = openEnded.getStream(streamIds[1]); + assertEq(actualStream, expectedStream); + } +} diff --git a/test/integration/create-and-deposit-multiple/createAndDepositMultiple.tree b/test/integration/create-and-deposit-multiple/createAndDepositMultiple.tree new file mode 100644 index 00000000..d4e32197 --- /dev/null +++ b/test/integration/create-and-deposit-multiple/createAndDepositMultiple.tree @@ -0,0 +1,9 @@ +createAndDepositMultiple.t.sol +├── when array counts are not equal +│ └── it should revert +└── when array counts are equal + ├── it should create multiple streams + ├── it should update the stream balance + ├── it should perform the ERC-20 transfer + ├── it should bump the next stream id multiple times + └── it should emit multiple {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events \ No newline at end of file diff --git a/test/integration/create-multiple/createMultiple.t.sol b/test/integration/create-multiple/createMultiple.t.sol new file mode 100644 index 00000000..849fd8db --- /dev/null +++ b/test/integration/create-multiple/createMultiple.t.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { Errors } from "src/libraries/Errors.sol"; +import { OpenEnded } from "src/types/DataTypes.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract CreateMultiple_Integration_Test is Integration_Test { + function setUp() public override { + Integration_Test.setUp(); + } + + function test_RevertWhen_RecipientsCountNotEqual() external whenNotDelegateCalled whenArrayCountsNotEqual { + address[] memory recipients = new address[](1); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_CreateMultipleArrayCountsNotEqual.selector, + recipients.length, + defaultSenders.length, + defaultRatesPerSecond.length + ) + ); + openEnded.createMultiple(recipients, defaultSenders, defaultRatesPerSecond, dai); + } + + function test_RevertWhen_SendersCountNotEqual() external whenNotDelegateCalled whenArrayCountsNotEqual { + address[] memory senders = new address[](1); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_CreateMultipleArrayCountsNotEqual.selector, + defaultRecipients.length, + senders.length, + defaultRatesPerSecond.length + ) + ); + openEnded.createMultiple(defaultRecipients, senders, defaultRatesPerSecond, dai); + } + + function test_RevertWhen_RatePerSecondCountNotEqual() external whenNotDelegateCalled whenArrayCountsNotEqual { + uint128[] memory ratesPerSecond = new uint128[](1); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_CreateMultipleArrayCountsNotEqual.selector, + defaultRecipients.length, + defaultSenders.length, + ratesPerSecond.length + ) + ); + openEnded.createMultiple(defaultRecipients, defaultSenders, ratesPerSecond, dai); + } + + function test_CreateMultiple() external whenNotDelegateCalled whenArrayCountsEqual { + uint256 beforeNextStreamId = openEnded.nextStreamId(); + + vm.expectEmit({ emitter: address(openEnded) }); + emit CreateOpenEndedStream({ + streamId: beforeNextStreamId, + sender: users.sender, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + lastTimeUpdate: uint40(block.timestamp) + }); + vm.expectEmit({ emitter: address(openEnded) }); + emit CreateOpenEndedStream({ + streamId: beforeNextStreamId + 1, + sender: users.sender, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + lastTimeUpdate: uint40(block.timestamp) + }); + + uint256[] memory streamIds = + openEnded.createMultiple(defaultRecipients, defaultSenders, defaultRatesPerSecond, dai); + + uint256 afterNextStreamId = openEnded.nextStreamId(); + + assertEq(streamIds[0], beforeNextStreamId, "streamIds[0] != beforeNextStreamId"); + assertEq(streamIds[1], beforeNextStreamId + 1, "streamIds[1] != beforeNextStreamId + 1"); + + assertEq(streamIds.length, defaultRecipients.length, "streamIds.length != defaultRecipients.length"); + assertEq( + beforeNextStreamId + defaultRecipients.length, + afterNextStreamId, + "afterNextStreamId != beforeNextStreamId + defaultRecipients.length" + ); + + OpenEnded.Stream memory expectedStream = OpenEnded.Stream({ + ratePerSecond: RATE_PER_SECOND, + asset: dai, + assetDecimals: 18, + balance: 0, + lastTimeUpdate: uint40(block.timestamp), + isCanceled: false, + isStream: true, + recipient: users.recipient, + sender: users.sender + }); + + OpenEnded.Stream memory actualStream = openEnded.getStream(streamIds[0]); + assertEq(actualStream, expectedStream); + + actualStream = openEnded.getStream(streamIds[1]); + assertEq(actualStream, expectedStream); + } +} diff --git a/test/integration/create-multiple/createMultiple.tree b/test/integration/create-multiple/createMultiple.tree new file mode 100644 index 00000000..51718611 --- /dev/null +++ b/test/integration/create-multiple/createMultiple.tree @@ -0,0 +1,12 @@ +createMultiple.t.sol +├── when array counts are not equal +│ ├── when the recipients array is not equal +│ │ └── it should revert +│ ├── when the senders array is not equal +│ │ └── it should revert +│ └── when the rates per second array is not equal +│ └── it should revert +└── when array counts are equal + ├── it should create multiple streams + ├── it should bump the next stream id multiple times + └── it should emit multiple {CreateOpenEndedStream} events \ No newline at end of file diff --git a/test/integration/create/create.t.sol b/test/integration/create/create.t.sol index c79c68e5..ce6ba201 100644 --- a/test/integration/create/create.t.sol +++ b/test/integration/create/create.t.sol @@ -17,7 +17,7 @@ contract Create_Integration_Test is Integration_Test { function test_RevertWhen_DelegateCall() external { bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.create, (users.sender, users.recipient, RATE_PER_SECOND, dai)); - _test_RevertWhen_DelegateCall(callData); + expectRevertDueToDelegateCall(callData); } function test_RevertWhen_SenderZeroAddress() external whenNotDelegateCalled { diff --git a/test/integration/deposit/deposit.t.sol b/test/integration/deposit/deposit.t.sol index 8aca0fee..f8461346 100644 --- a/test/integration/deposit/deposit.t.sol +++ b/test/integration/deposit/deposit.t.sol @@ -15,16 +15,16 @@ contract Deposit_Integration_Test is Integration_Test { function test_RevertWhen_DelegateCall() external { bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.deposit, (defaultStreamId, DEPOSIT_AMOUNT)); - _test_RevertWhen_DelegateCall(callData); + expectRevertDueToDelegateCall(callData); } function test_RevertGiven_Null() external whenNotDelegateCalled { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.deposit(nullStreamId, DEPOSIT_AMOUNT); } function test_RevertGiven_Canceled() external whenNotDelegateCalled givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.deposit(defaultStreamId, DEPOSIT_AMOUNT); } @@ -57,7 +57,12 @@ contract Deposit_Integration_Test is Integration_Test { }); vm.expectEmit({ emitter: address(openEnded) }); - emit DepositOpenEndedStream({ streamId: streamId, funder: users.sender, asset: asset, amount: DEPOSIT_AMOUNT }); + emit DepositOpenEndedStream({ + streamId: streamId, + funder: users.sender, + asset: asset, + depositAmount: DEPOSIT_AMOUNT + }); expectCallToTransferFrom({ asset: asset, diff --git a/test/integration/refund-from-stream/refundFromStream.t.sol b/test/integration/refund-from-stream/refundFromStream.t.sol index aa160e83..f802696d 100644 --- a/test/integration/refund-from-stream/refundFromStream.t.sol +++ b/test/integration/refund-from-stream/refundFromStream.t.sol @@ -19,16 +19,16 @@ contract RefundFromStream_Integration_Test is Integration_Test { function test_RevertWhen_DelegateCall() external { bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.refundFromStream, (defaultStreamId, REFUND_AMOUNT)); - _test_RevertWhen_DelegateCall(callData); + expectRevertDueToDelegateCall(callData); } function test_RevertGiven_Null() external whenNotDelegateCalled { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.refundFromStream({ streamId: nullStreamId, amount: REFUND_AMOUNT }); } function test_RevertGiven_Canceled() external whenNotDelegateCalled givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.refundFromStream({ streamId: defaultStreamId, amount: REFUND_AMOUNT }); } @@ -46,19 +46,16 @@ contract RefundFromStream_Integration_Test is Integration_Test { openEnded.refundFromStream({ streamId: defaultStreamId, amount: REFUND_AMOUNT }); } - function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty(address maliciousThirdParty) + function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty() external whenNotDelegateCalled givenNotNull givenNotCanceled whenCallerUnauthorized { - vm.assume(maliciousThirdParty != users.sender && maliciousThirdParty != users.recipient); - resetPrank({ msgSender: maliciousThirdParty }); + resetPrank({ msgSender: users.eve }); vm.expectRevert( - abi.encodeWithSelector( - Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, maliciousThirdParty - ) + abi.encodeWithSelector(Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, users.eve) ); openEnded.refundFromStream({ streamId: defaultStreamId, amount: REFUND_AMOUNT }); } @@ -132,7 +129,12 @@ contract RefundFromStream_Integration_Test is Integration_Test { }); vm.expectEmit({ emitter: address(openEnded) }); - emit RefundFromOpenEndedStream({ streamId: streamId, sender: users.sender, asset: asset, amount: REFUND_AMOUNT }); + emit RefundFromOpenEndedStream({ + streamId: streamId, + sender: users.sender, + asset: asset, + refundAmount: REFUND_AMOUNT + }); expectCallToTransfer({ asset: asset, to: users.sender, amount: normalizeTransferAmount(streamId, REFUND_AMOUNT) }); openEnded.refundFromStream({ streamId: streamId, amount: REFUND_AMOUNT }); diff --git a/test/integration/refundable-amount-of/refundableAmountOf.t.sol b/test/integration/refundable-amount-of/refundableAmountOf.t.sol index d75d41f1..7af87ead 100644 --- a/test/integration/refundable-amount-of/refundableAmountOf.t.sol +++ b/test/integration/refundable-amount-of/refundableAmountOf.t.sol @@ -9,12 +9,12 @@ contract RefundableAmountOf_Integration_Test is Integration_Test { } function test_RevertGiven_Null() external { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.refundableAmountOf(nullStreamId); } function test_RevertGiven_Canceled() external givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.refundableAmountOf(defaultStreamId); } diff --git a/test/integration/restart-stream/restartStream.t.sol b/test/integration/restart-stream/restartStream.t.sol index 45edecc2..7c29e173 100644 --- a/test/integration/restart-stream/restartStream.t.sol +++ b/test/integration/restart-stream/restartStream.t.sol @@ -15,11 +15,11 @@ contract RestartStream_Integration_Test is Integration_Test { function test_RevertWhen_DelegateCall() external { bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.restartStream, (defaultStreamId, RATE_PER_SECOND)); - _test_RevertWhen_DelegateCall(callData); + expectRevertDueToDelegateCall(callData); } function test_RevertGiven_Null() external whenNotDelegateCalled { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.restartStream({ streamId: nullStreamId, ratePerSecond: RATE_PER_SECOND }); } @@ -43,19 +43,16 @@ contract RestartStream_Integration_Test is Integration_Test { openEnded.restartStream({ streamId: defaultStreamId, ratePerSecond: RATE_PER_SECOND }); } - function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty(address maliciousThirdParty) + function test_RevertWhen_CallerUnauthorized_MaliciousThirdParty() external whenNotDelegateCalled givenNotNull givenCanceled whenCallerUnauthorized { - vm.assume(maliciousThirdParty != users.sender && maliciousThirdParty != users.recipient); - resetPrank({ msgSender: maliciousThirdParty }); + resetPrank({ msgSender: users.eve }); vm.expectRevert( - abi.encodeWithSelector( - Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, maliciousThirdParty - ) + abi.encodeWithSelector(Errors.SablierV2OpenEnded_Unauthorized.selector, defaultStreamId, users.eve) ); openEnded.restartStream({ streamId: defaultStreamId, ratePerSecond: RATE_PER_SECOND }); } diff --git a/test/integration/stream-debt-of/streamDebtOf.t.sol b/test/integration/stream-debt-of/streamDebtOf.t.sol new file mode 100644 index 00000000..b38aa937 --- /dev/null +++ b/test/integration/stream-debt-of/streamDebtOf.t.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { Integration_Test } from "../Integration.t.sol"; + +contract StreamDebtOf_Integration_Test is Integration_Test { + function setUp() public override { + Integration_Test.setUp(); + } + + function test_RevertGiven_Null() external { + expectRevertNull(); + openEnded.streamDebtOf(nullStreamId); + } + + function test_RevertGiven_Canceled() external givenNotNull { + expectRevertCanceled(); + openEnded.streamDebtOf(defaultStreamId); + } + + function test_StreamDebtOf_BalanceGreaterThanOrEqualStreamedAmount() external givenNotNull givenNotCanceled { + defaultDeposit(); + uint128 streamDebt = openEnded.streamDebtOf(defaultStreamId); + + assertEq(streamDebt, 0, "stream debt"); + } + + function test_streamDebtOf() external givenNotNull givenNotCanceled { + vm.warp({ newTimestamp: WARP_ONE_MONTH }); + uint128 streamDebt = openEnded.streamDebtOf(defaultStreamId); + + assertEq(streamDebt, ONE_MONTH_STREAMED_AMOUNT, "stream debt"); + } +} diff --git a/test/integration/stream-debt/streamDebt.tree b/test/integration/stream-debt-of/streamDebtOf.tree similarity index 96% rename from test/integration/stream-debt/streamDebt.tree rename to test/integration/stream-debt-of/streamDebtOf.tree index 18102d58..db21f29e 100644 --- a/test/integration/stream-debt/streamDebt.tree +++ b/test/integration/stream-debt-of/streamDebtOf.tree @@ -1,4 +1,4 @@ -streamDebt.t.sol +streamDebtOf.t.sol ├── given the id references a null stream │ └── it should revert └── given the id does not reference a null stream diff --git a/test/integration/stream-debt/streamDebt.t.sol b/test/integration/stream-debt/streamDebt.t.sol deleted file mode 100644 index e7cce4d9..00000000 --- a/test/integration/stream-debt/streamDebt.t.sol +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.22; - -import { Integration_Test } from "../Integration.t.sol"; - -contract StreamDebt_Integration_Test is Integration_Test { - function setUp() public override { - Integration_Test.setUp(); - } - - function test_RevertGiven_Null() external { - _test_RevertGiven_Null(); - openEnded.streamDebt(nullStreamId); - } - - function test_RevertGiven_Canceled() external givenNotNull { - _test_RevertGiven_Canceled(); - openEnded.streamDebt(defaultStreamId); - } - - function test_StreamDebt_BalanceGreaterThanOrEqualStreamedAmount() external givenNotNull givenNotCanceled { - defaultDeposit(); - uint128 streamDebt = openEnded.streamDebt(defaultStreamId); - assertEq(streamDebt, 0, "stream debt"); - } - - function test_StreamDebt() external givenNotNull givenNotCanceled { - vm.warp({ newTimestamp: WARP_ONE_MONTH }); - uint128 streamDebt = openEnded.streamDebt(defaultStreamId); - assertEq(streamDebt, ONE_MONTH_STREAMED_AMOUNT, "stream debt"); - } -} diff --git a/test/integration/streamed-amount-of/streamedAmountOf.t.sol b/test/integration/streamed-amount-of/streamedAmountOf.t.sol index 61b4c7e4..814bb589 100644 --- a/test/integration/streamed-amount-of/streamedAmountOf.t.sol +++ b/test/integration/streamed-amount-of/streamedAmountOf.t.sol @@ -9,12 +9,12 @@ contract StreamedAmountOf_Integration_Test is Integration_Test { } function test_RevertGiven_Null() external { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.streamedAmountOf(nullStreamId); } function test_RevertGiven_Canceled() external givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.streamedAmountOf(defaultStreamId); } diff --git a/test/integration/withdraw-multiple/withdrawMultiple.t.sol b/test/integration/withdraw-multiple/withdrawMultiple.t.sol new file mode 100644 index 00000000..d04148c9 --- /dev/null +++ b/test/integration/withdraw-multiple/withdrawMultiple.t.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { ISablierV2OpenEnded } from "src/interfaces/ISablierV2OpenEnded.sol"; +import { Errors } from "src/libraries/Errors.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { + uint40[] internal times; + + function setUp() public override { + Integration_Test.setUp(); + + times.push(WITHDRAW_TIME); + times.push(WITHDRAW_TIME); + vm.warp({ newTimestamp: WARP_ONE_MONTH }); + } + + function test_RevertWhen_DelegateCall() external { + bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.withdrawMultiple, (defaultStreamIds, times)); + expectRevertDueToDelegateCall(callData); + } + + function test_RevertWhen_ArrayCountsNotEqual() external whenNotDelegateCalled { + uint256[] memory streamIds = new uint256[](0); + uint40[] memory _times = new uint40[](1); + vm.expectRevert( + abi.encodeWithSelector(Errors.SablierV2OpenEnded_WithdrawMultipleArrayCountsNotEqual.selector, 0, 1) + ); + openEnded.withdrawMultiple(streamIds, _times); + } + + modifier whenArrayCountsAreEqual() { + _; + } + + function test_WithdrawMultiple_ArrayCountsZero() external whenNotDelegateCalled whenArrayCountsAreEqual { + uint256[] memory streamIds = new uint256[](0); + uint40[] memory _times = new uint40[](0); + openEnded.withdrawMultiple(streamIds, _times); + } + + modifier whenArrayCountsNotZero() { + _; + } + + function test_RevertGiven_OnlyNull() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + { + defaultStreamIds[0] = nullStreamId; + defaultStreamIds[1] = nullStreamId; + vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_Null.selector, nullStreamId)); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertGiven_SomeNull() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + { + defaultStreamIds[0] = nullStreamId; + vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_Null.selector, nullStreamId)); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertGiven_OnlyCanceled() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + { + openEnded.cancel(defaultStreamIds[1]); + expectRevertCanceled(); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertGiven_SomeCanceled() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + { + expectRevertCanceled(); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertWhen_OnlyWithdrawalTimesNotGreaterThanLastTimeUpdate() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + givenNotCanceled + { + uint40 lastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[0]); + times[0] = lastTimeUpdate; + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_WithdrawalTimeNotGreaterThanLastUpdate.selector, + lastTimeUpdate, + lastTimeUpdate + ) + ); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertWhen_SomeWithdrawalTimesNotGreaterThanLastTimeUpdate() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + givenNotCanceled + { + uint40 lastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[0]); + times[0] = lastTimeUpdate; + times[1] = lastTimeUpdate; + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_WithdrawalTimeNotGreaterThanLastUpdate.selector, + lastTimeUpdate, + lastTimeUpdate + ) + ); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertWhen_OnlyWithdrawalTimesInTheFuture() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + givenNotCanceled + whenWithdrawalTimeGreaterThanLastUpdate + { + uint40 futureTime = uint40(block.timestamp + 1); + times[0] = futureTime; + times[1] = futureTime; + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_WithdrawalTimeInTheFuture.selector, futureTime, WARP_ONE_MONTH + ) + ); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertWhen_SomeWithdrawalTimesInTheFuture() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + givenNotCanceled + whenWithdrawalTimeGreaterThanLastUpdate + { + defaultDeposit(); + + uint40 futureTime = uint40(block.timestamp + 1); + times[1] = futureTime; + + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_WithdrawalTimeInTheFuture.selector, futureTime, WARP_ONE_MONTH + ) + ); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertGiven_OnlyZeroBalances() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + givenNotCanceled + whenWithdrawalTimeGreaterThanLastUpdate + whenWithdrawalTimeNotInTheFuture + { + vm.expectRevert( + abi.encodeWithSelector(Errors.SablierV2OpenEnded_WithdrawBalanceZero.selector, defaultStreamIds[0]) + ); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_RevertGiven_SomeZeroBalances() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + givenNotCanceled + whenWithdrawalTimeGreaterThanLastUpdate + whenWithdrawalTimeNotInTheFuture + { + defaultDeposit(); + + vm.expectRevert( + abi.encodeWithSelector(Errors.SablierV2OpenEnded_WithdrawBalanceZero.selector, defaultStreamIds[1]) + ); + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + } + + function test_WithdrawMultiple() + external + whenNotDelegateCalled + whenArrayCountsAreEqual + whenArrayCountsNotZero + givenNotNull + givenNotCanceled + whenWithdrawalTimeGreaterThanLastUpdate + whenWithdrawalTimeNotInTheFuture + givenBalanceNotZero + { + defaultDeposit(); + defaultDeposit(defaultStreamIds[1]); + + uint40 actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[0]); + uint40 expectedLastTimeUpdate = uint40(block.timestamp - ONE_MONTH); + assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); + + actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[1]); + assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); + + vm.expectEmit({ emitter: address(openEnded) }); + emit WithdrawFromOpenEndedStream({ + streamId: defaultStreamIds[0], + to: users.recipient, + asset: dai, + withdrawAmount: WITHDRAW_AMOUNT + }); + vm.expectEmit({ emitter: address(openEnded) }); + emit WithdrawFromOpenEndedStream({ + streamId: defaultStreamIds[1], + to: users.recipient, + asset: dai, + withdrawAmount: WITHDRAW_AMOUNT + }); + + openEnded.withdrawMultiple({ streamIds: defaultStreamIds, times: times }); + + actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[0]); + expectedLastTimeUpdate = WITHDRAW_TIME; + assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); + + actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[1]); + assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); + + uint128 actualStreamBalance = openEnded.getBalance(defaultStreamIds[0]); + uint128 expectedStreamBalance = DEPOSIT_AMOUNT - WITHDRAW_AMOUNT; + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + + actualStreamBalance = openEnded.getBalance(defaultStreamIds[1]); + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + } +} diff --git a/test/integration/withdraw-multiple/withdrawMultiple.tree b/test/integration/withdraw-multiple/withdrawMultiple.tree new file mode 100644 index 00000000..c1fccd8a --- /dev/null +++ b/test/integration/withdraw-multiple/withdrawMultiple.tree @@ -0,0 +1,38 @@ +withdrawMultiple.t.sol +├── when delegate called +│ └── it should revert +└── when not delegate called + ├── when the input array counts are not equal + │ └── it should revert + └── when the input array counts are equal + ├── when the array counts are zero + │ └── it should do nothing + └── when the array counts are not zero + ├── given the stream IDs array references only null streams + │ └── it should revert + ├── given the stream IDs array references some null streams + │ └── it should revert + └── given the stream IDs array references only non-null streams + ├── given the stream IDs array references only canceled streams + │ └── it should revert + ├── given the stream IDs array references some canceled streams + │ └── it should revert + └── given the stream IDs array references only non-canceled streams + ├── when all withdrawal times are not strictly greater than the last time update + │ └── it should revert + ├── when some withdrawal times are not strictly greater than the last time update + │ └── it should revert + └── when none withdrawal times are strictly greater than the last time update + ├── when all withdrawal times are in the future + │ └── it should revert + ├── when some withdrawal times are in the future + │ └── it should revert + └── when none withdrawal times are in the future + ├── given all balances are zero + │ └── it should revert + ├── given some balances are zero + │ └── it should revert + └── given all balances are greater than zero + ├── it should make the withdrawals + ├── it should update the times + └── it should emit multiple {WithdrawFromOpenEndedStream} events \ No newline at end of file diff --git a/test/integration/withdraw/withdraw.t.sol b/test/integration/withdraw/withdraw.t.sol index 40cfe9de..31ef035f 100644 --- a/test/integration/withdraw/withdraw.t.sol +++ b/test/integration/withdraw/withdraw.t.sol @@ -20,16 +20,16 @@ contract Withdraw_Integration_Test is Integration_Test { function test_RevertWhen_DelegateCall() external { bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.withdraw, (defaultStreamId, users.recipient, WITHDRAW_TIME)); - _test_RevertWhen_DelegateCall(callData); + expectRevertDueToDelegateCall(callData); } function test_RevertGiven_Null() external whenNotDelegateCalled { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.withdraw({ streamId: nullStreamId, to: users.recipient, time: WITHDRAW_TIME }); } function test_RevertGiven_Canceled() external whenNotDelegateCalled givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.withdraw({ streamId: defaultStreamId, to: users.recipient, time: WITHDRAW_TIME }); } @@ -120,7 +120,7 @@ contract Withdraw_Integration_Test is Integration_Test { openEnded.withdraw({ streamId: defaultStreamId, to: users.recipient, time: futureTime }); } - function test_RevertWhen_BalanceZero() + function test_RevertGiven_BalanceZero() external whenNotDelegateCalled givenNotNull @@ -147,6 +147,7 @@ contract Withdraw_Integration_Test is Integration_Test { whenWithdrawalAddressIsRecipient whenWithdrawalTimeGreaterThanLastUpdate whenWithdrawalTimeNotInTheFuture + givenBalanceNotZero { openEnded.withdraw({ streamId: defaultStreamId, to: users.recipient, time: WITHDRAW_TIME }); @@ -168,6 +169,7 @@ contract Withdraw_Integration_Test is Integration_Test { whenWithdrawalAddressIsRecipient whenWithdrawalTimeGreaterThanLastUpdate whenWithdrawalTimeNotInTheFuture + givenBalanceNotZero { address unknownCaller = address(0xCAFE); resetPrank({ msgSender: unknownCaller }); @@ -192,6 +194,7 @@ contract Withdraw_Integration_Test is Integration_Test { whenWithdrawalAddressIsRecipient whenWithdrawalTimeGreaterThanLastUpdate whenWithdrawalTimeNotInTheFuture + givenBalanceNotZero whenCallerRecipient { // Set the timestamp to 1 month ago to create the stream with the same `lastTimeUpdate` as `defaultStreamId`. @@ -212,6 +215,7 @@ contract Withdraw_Integration_Test is Integration_Test { whenWithdrawalAddressIsRecipient whenWithdrawalTimeGreaterThanLastUpdate whenWithdrawalTimeNotInTheFuture + givenBalanceNotZero whenCallerRecipient { _test_Withdraw(defaultStreamId, dai); @@ -236,7 +240,7 @@ contract Withdraw_Integration_Test is Integration_Test { streamId: streamId, to: users.recipient, asset: asset, - amount: WITHDRAW_AMOUNT + withdrawAmount: WITHDRAW_AMOUNT }); expectCallToTransfer({ diff --git a/test/integration/withdraw/withdraw.tree b/test/integration/withdraw/withdraw.tree index 4a262b41..019148d6 100644 --- a/test/integration/withdraw/withdraw.tree +++ b/test/integration/withdraw/withdraw.tree @@ -23,9 +23,9 @@ withdraw.t.sol ├── when the withdrawal time is in the future │ └── it should revert └── when the withdrawal time is not in the future - ├── when the balance is zero + ├── given the balance is zero │ └── it should revert - └── when the balance is not zero + └── given the balance is not zero ├── when the caller is not the recipient │ ├── when the caller is the sender │ │ └── it should make the withdrawal diff --git a/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol b/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol index a9467f26..d9d678f2 100644 --- a/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol +++ b/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol @@ -9,12 +9,12 @@ contract WithdrawableAmountOf_Integration_Test is Integration_Test { } function test_RevertGiven_Null() external { - _test_RevertGiven_Null(); + expectRevertNull(); openEnded.withdrawableAmountOf(nullStreamId); } function test_RevertGiven_Canceled() external givenNotNull { - _test_RevertGiven_Canceled(); + expectRevertCanceled(); openEnded.withdrawableAmountOf(defaultStreamId); } diff --git a/test/utils/Events.sol b/test/utils/Events.sol index 026e1891..2d70e7a0 100644 --- a/test/utils/Events.sol +++ b/test/utils/Events.sol @@ -33,11 +33,11 @@ abstract contract Events { ); event DepositOpenEndedStream( - uint256 indexed streamId, address indexed funder, IERC20 indexed asset, uint128 amount + uint256 indexed streamId, address indexed funder, IERC20 indexed asset, uint128 depositAmount ); event RefundFromOpenEndedStream( - uint256 indexed streamId, address indexed sender, IERC20 indexed asset, uint128 amount + uint256 indexed streamId, address indexed sender, IERC20 indexed asset, uint128 refundAmount ); event RestartOpenEndedStream( @@ -45,6 +45,6 @@ abstract contract Events { ); event WithdrawFromOpenEndedStream( - uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 amount + uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 withdrawAmount ); } diff --git a/test/utils/Modifiers.sol b/test/utils/Modifiers.sol index 40ec01d0..ba8eac9e 100644 --- a/test/utils/Modifiers.sol +++ b/test/utils/Modifiers.sol @@ -38,6 +38,14 @@ abstract contract Modifiers { _; } + /*////////////////////////////////////////////////////////////////////////// + CANCEL-MULTIPLE + //////////////////////////////////////////////////////////////////////////*/ + + modifier whenArrayCountNotZero() { + _; + } + /*////////////////////////////////////////////////////////////////////////// CREATE //////////////////////////////////////////////////////////////////////////*/ @@ -54,6 +62,18 @@ abstract contract Modifiers { _; } + /*////////////////////////////////////////////////////////////////////////// + CREATE-MULTIPLE + //////////////////////////////////////////////////////////////////////////*/ + + modifier whenArrayCountsNotEqual() { + _; + } + + modifier whenArrayCountsEqual() { + _; + } + /*////////////////////////////////////////////////////////////////////////// DEPOSIT //////////////////////////////////////////////////////////////////////////*/ @@ -86,7 +106,7 @@ abstract contract Modifiers { WITHDRAW //////////////////////////////////////////////////////////////////////////*/ - modifier whenToNonZeroAddress() { + modifier givenBalanceNotZero() { _; } @@ -94,6 +114,10 @@ abstract contract Modifiers { _; } + modifier whenToNonZeroAddress() { + _; + } + modifier whenWithdrawalAddressIsRecipient() { _; }