From 73c0dce45482b2b5e77ecec15a1b8db7e9513b52 Mon Sep 17 00:00:00 2001 From: Andrei Vlad Birgaoanu <99738872+andreivladbrg@users.noreply.github.com> Date: Mon, 20 May 2024 23:18:06 +0300 Subject: [PATCH] Add ERC721 support (#82) * refactor: rename function to withdrawMultipleAt * feat: implement NFT in OE contract * test: update tests accordingly * refactor: use modifiers only in public/external functions * test: add transferFrom tests test: remove ERC20 events test: add ERC721 transfer event test: remove unneded delegatecall tests test: update create tests * test: withdrawMax function * test: withdrawMaxMultiple function * feat: use metadata modifier in all relevant functions test: update tests accordingly * docs: add access control table * build: use shanghai evm version * docs: correct natspec in ISablierV2OpenEnded * fix: remove to check in _update hook feat: inherit IERC721Metadata in ISablierV2OpenEndedState refactor: add constructor in SablierV2OpenEnded --- README.md | 38 ++- foundry.toml | 6 +- src/SablierV2OpenEnded.sol | 276 ++++++++++-------- src/abstracts/SablierV2OpenEndedState.sol | 93 +++++- src/interfaces/ISablierV2OpenEnded.sol | 77 +++-- src/interfaces/ISablierV2OpenEndedState.sol | 12 +- src/libraries/Errors.sol | 21 +- src/types/DataTypes.sol | 11 +- test/Base.t.sol | 1 + test/integration/Integration.t.sol | 5 +- .../adjustRatePerSecond.t.sol | 106 ++++--- .../adjustRatePerSecond.tree | 22 +- .../cancel-multiple/cancelMultiple.t.sol | 6 +- test/integration/cancel/cancel.t.sol | 75 +++-- test/integration/cancel/cancel.tree | 39 ++- .../createAndDepositMultiple.t.sol | 7 +- .../create-multiple/createMultiple.t.sol | 28 +- .../create-multiple/createMultiple.tree | 2 + test/integration/create/create.t.sol | 53 +++- test/integration/create/create.tree | 2 + test/integration/deposit/deposit.t.sol | 5 +- test/integration/deposit/deposit.tree | 6 +- .../refund-from-stream/refundFromStream.t.sol | 2 +- .../restart-stream/restartStream.t.sol | 12 +- .../restart-stream/restartStream.tree | 3 +- .../transfer-from/transferFrom.t.sol | 50 ++++ .../transfer-from/transferFrom.tree | 7 + .../withdrawAtMultiple.t.sol | 106 +------ .../withdrawAtMultiple.tree | 25 ++ .../withdrawAt.t.sol | 131 ++++++--- test/integration/withdraw-at/withdrawAt.tree | 53 ++++ .../withdrawMaxMultiple.t.sol | 92 ++++++ .../withdrawMaxMultiple.tree | 17 ++ .../withdraw-max/withdrawMax.t.sol | 78 +++++ .../integration/withdraw-max/withdrawMax.tree | 10 + .../withdraw-multiple/withdrawAtMultiple.tree | 38 --- test/integration/withdraw/withdrawAt.tree | 46 --- .../withdrawableAmountOf.t.sol | 93 +++++- .../withdrawableAmountOf.tree | 9 +- test/invariant/OpenEnded.t.sol | 83 ++++-- .../handlers/OpenEndedCreateHandler.sol | 51 ++-- test/invariant/handlers/OpenEndedHandler.sol | 62 +++- test/invariant/stores/OpenEndedStore.sol | 9 + test/utils/Assertions.sol | 1 - test/utils/Events.sol | 24 +- test/utils/Modifiers.sol | 54 +++- 46 files changed, 1301 insertions(+), 646 deletions(-) create mode 100644 test/integration/transfer-from/transferFrom.t.sol create mode 100644 test/integration/transfer-from/transferFrom.tree rename test/integration/{withdraw-multiple => withdraw-at-multiple}/withdrawAtMultiple.t.sol (65%) create mode 100644 test/integration/withdraw-at-multiple/withdrawAtMultiple.tree rename test/integration/{withdraw => withdraw-at}/withdrawAt.t.sol (67%) create mode 100644 test/integration/withdraw-at/withdrawAt.tree create mode 100644 test/integration/withdraw-max-multiple/withdrawMaxMultiple.t.sol create mode 100644 test/integration/withdraw-max-multiple/withdrawMaxMultiple.tree create mode 100644 test/integration/withdraw-max/withdrawMax.t.sol create mode 100644 test/integration/withdraw-max/withdrawMax.tree delete mode 100644 test/integration/withdraw-multiple/withdrawAtMultiple.tree delete mode 100644 test/integration/withdraw/withdrawAt.tree diff --git a/README.md b/README.md index 330fee9c..83f6ce17 100644 --- a/README.md +++ b/README.md @@ -125,20 +125,19 @@ way he may extract more assets from stream. We store the asset decimals, so that we don't have to make an external call to get the decimals of the asset each time a deposit or an extraction is made. Decimals are `uint8`, meaning it is not an expensive to store them. -Recipient address **must** be checked because there is no NFT minted in `_create` function. - Sender address **must** be checked because there is no `ERC20` transfer in `_create` function. -In `_cancel` function we can perform both sender and recipient `ERC20` transfers because there is no NFT so we don’t -have to worry about [this issue](https://github.com/cantinasec/review-sablier/issues/11). - ### Invariants: -_balance = withdrawable amount + refundable amount_ +_withdrawable amount = min(balance, streamed amount) + remaining amount_ + +_balance = withdrawable amount + refundable amount - remaining amount_ _balance = sum of deposits - sum of withdrawals_ -_withdrawable amount ≤ streamed amount_ +_withdrawable amount - remaining amount ≤ streamed amount_ + +_sum of withdrawn amounts ≤ sum of deposits_ _sum of withdrawn amounts ≤ sum of deposits_ @@ -146,19 +145,16 @@ _sum of stream balances normilized to asset decimals ≤ asset.balanceOf(Sablier _lastTimeUpdate ≤ block.timestamp;_ -_if(isCanceled = true) then balance = 0 && ratePerSecond = 0_ - -### Questions: - -Should we update the time in `_cancel`? - -Should we add `TimeUpdated` event? - -Should we add `pause` function? Basically it would be a duplication of `cancel` function. +_if(isCanceled = true) then balance = 0 && ratePerSecond = 0 && withdrawable amount = remaining amount_ -### TODOs: +### Actions Access Control: -- createMultiple -- withdrawMultiple -- add broker fees - - The fee should be on `create` or on `deposit` ? both? +| Action | Sender | Recipient | Operator(s) | Unkown User | +| ------------------- | :----: | :-------: | :---------: | :--------------------: | +| AdjustRatePerSecond | ✅ | ❌ | ❌ | ❌ | +| Cancel | ✅ | ❌ | ❌ | ❌ | +| Deposit | ✅ | ✅ | ✅ | ✅ | +| RefundFromStream | ✅ | ❌ | ❌ | ❌ | +| RestartStream | ✅ | ❌ | ❌ | ❌ | +| Transfer NFT | ❌ | ✅ | ✅ | ❌ | +| Withdraw | ✅ | ✅ | ✅ | ✅ (only to Recipient) | diff --git a/foundry.toml b/foundry.toml index 0a60aabd..6145a01e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,12 +1,10 @@ [profile.default] auto_detect_solc = false bytecode_hash = "none" - evm_version = "paris" + evm_version = "shanghai" fs_permissions = [ { access = "read", path = "package.json" }, - { access = "read", path = "./out" }, - { access = "read", path = "./out-optimized" }, - { access = "read-write", path = "./cache" }, + { access = "read", path = "./out-optimized" } ] gas_reports = ["*"] optimizer = true diff --git a/src/SablierV2OpenEnded.sol b/src/SablierV2OpenEnded.sol index 0eb1cd24..6e06bf10 100644 --- a/src/SablierV2OpenEnded.sol +++ b/src/SablierV2OpenEnded.sol @@ -5,6 +5,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; 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 { NoDelegateCall } from "./abstracts/NoDelegateCall.sol"; import { SablierV2OpenEndedState } from "./abstracts/SablierV2OpenEndedState.sol"; @@ -14,10 +15,20 @@ import { OpenEnded } from "./types/DataTypes.sol"; /// @title SablierV2OpenEnded /// @notice See the documentation in {ISablierV2OpenEnded}. -contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2OpenEndedState { +contract SablierV2OpenEnded is + NoDelegateCall, // 0 inherited components + ISablierV2OpenEnded, // 1 inherited components + SablierV2OpenEndedState // 7 inherited components +{ using SafeCast for uint256; using SafeERC20 for IERC20; + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////*/ + + constructor() ERC721("Sablier V2 Open Ended NFT", "SAB-V2-OPEN-EN") { } + /*////////////////////////////////////////////////////////////////////////// CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ @@ -27,8 +38,8 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope external view override - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) returns (uint128 refundableAmount) { refundableAmount = _refundableAmountOf(streamId, uint40(block.timestamp)); @@ -42,8 +53,8 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope external view override - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) returns (uint128 refundableAmount) { refundableAmount = _refundableAmountOf(streamId, time); @@ -54,8 +65,8 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope external view override - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) returns (uint128 debt) { uint128 balance = _streams[streamId].balance; @@ -73,8 +84,8 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope external view override - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) returns (uint128 streamedAmount) { streamedAmount = _streamedAmountOf(streamId, uint40(block.timestamp)); @@ -88,23 +99,16 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope external view override - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) returns (uint128 streamedAmount) { streamedAmount = _streamedAmountOf(streamId, time); } /// @inheritdoc ISablierV2OpenEnded - function withdrawableAmountOf(uint256 streamId) - external - view - override - notCanceled(streamId) - notNull(streamId) - returns (uint128 withdrawableAmount) - { - withdrawableAmount = _withdrawableAmountOf(streamId, uint40(block.timestamp)); + function withdrawableAmountOf(uint256 streamId) external view override returns (uint128 withdrawableAmount) { + withdrawableAmount = withdrawableAmountOf(streamId, uint40(block.timestamp)); } /// @inheritdoc ISablierV2OpenEnded @@ -112,14 +116,22 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope uint256 streamId, uint40 time ) - external + public view override - notCanceled(streamId) notNull(streamId) returns (uint128 withdrawableAmount) { - withdrawableAmount = _withdrawableAmountOf(streamId, time); + uint128 remainingAmount = _streams[streamId].remainingAmount; + + // If the stream is canceled, return the remaining amount. + if (_streams[streamId].isCanceled) { + return remainingAmount; + } + // Otherwise, calculate the withdrawable amount and sum it with the remaining amount. + else { + withdrawableAmount = _withdrawableAmountOf(streamId, time) + remainingAmount; + } } /*////////////////////////////////////////////////////////////////////////// @@ -134,9 +146,10 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope external override noDelegateCall - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) onlySender(streamId) + updateMetadata(streamId) { // Effects and Interactions: adjust the stream. _adjustRatePerSecond(streamId, newRatePerSecond); @@ -147,10 +160,12 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope public override noDelegateCall - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) onlySender(streamId) + updateMetadata(streamId) { + // Checks, Effects and Interactions: cancel the stream. _cancel(streamId); } @@ -169,14 +184,16 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope address sender, address recipient, uint128 ratePerSecond, - IERC20 asset + IERC20 asset, + bool isTransferable ) - external + public override + noDelegateCall returns (uint256 streamId) { // Checks, Effects and Interactions: create the stream. - streamId = _create(sender, recipient, ratePerSecond, asset); + streamId = _create(sender, recipient, ratePerSecond, asset, isTransferable); } /// @inheritdoc ISablierV2OpenEnded @@ -185,6 +202,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope address recipient, uint128 ratePerSecond, IERC20 asset, + bool isTransferable, uint128 amount ) external @@ -192,7 +210,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope returns (uint256 streamId) { // Checks, Effects and Interactions: create the stream. - streamId = _create(sender, recipient, ratePerSecond, asset); + streamId = create(sender, recipient, ratePerSecond, asset, isTransferable); // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); @@ -203,10 +221,12 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope address[] calldata recipients, address[] calldata senders, uint128[] calldata ratesPerSecond, - IERC20 asset + IERC20 asset, + bool[] calldata isTransferable ) public override + noDelegateCall returns (uint256[] memory streamIds) { uint256 recipientsCount = recipients.length; @@ -223,7 +243,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope 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); + streamIds[i] = _create(senders[i], recipients[i], ratesPerSecond[i], asset, isTransferable[i]); } } @@ -233,6 +253,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope address[] calldata senders, uint128[] calldata ratesPerSecond, IERC20 asset, + bool[] calldata isTransferable, uint128[] calldata amounts ) external @@ -240,7 +261,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope returns (uint256[] memory streamIds) { streamIds = new uint256[](recipients.length); - streamIds = createMultiple(recipients, senders, ratesPerSecond, asset); + streamIds = createMultiple(recipients, senders, ratesPerSecond, asset, isTransferable); uint256 streamIdsCount = streamIds.length; if (streamIdsCount != amounts.length) { @@ -259,18 +280,19 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope uint256 streamId, uint128 amount ) - external + public override noDelegateCall - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) + updateMetadata(streamId) { // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); } /// @inheritdoc ISablierV2OpenEnded - function depositMultiple(uint256[] memory streamIds, uint128[] calldata amounts) public override noDelegateCall { + function depositMultiple(uint256[] memory streamIds, uint128[] calldata amounts) external override { uint256 streamIdsCount = streamIds.length; uint256 amountsCount = amounts.length; @@ -280,18 +302,23 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope } for (uint256 i = 0; i < streamIdsCount; ++i) { - // Check: the stream is not canceled. - if (isCanceled(streamIds[i])) { - revert Errors.SablierV2OpenEnded_StreamCanceled(streamIds[i]); - } - // Checks, Effects and Interactions: deposit on stream. - _deposit(streamIds[i], amounts[i]); + deposit(streamIds[i], amounts[i]); } } /// @inheritdoc ISablierV2OpenEnded - function restartStream(uint256 streamId, uint128 ratePerSecond) external override { + function restartStream( + uint256 streamId, + uint128 ratePerSecond + ) + public + override + noDelegateCall + notNull(streamId) + onlySender(streamId) + updateMetadata(streamId) + { // Checks, Effects and Interactions: restart the stream. _restartStream(streamId, ratePerSecond); } @@ -299,7 +326,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope /// @inheritdoc ISablierV2OpenEnded function restartStreamAndDeposit(uint256 streamId, uint128 ratePerSecond, uint128 amount) external override { // Checks, Effects and Interactions: restart the stream. - _restartStream(streamId, ratePerSecond); + restartStream(streamId, ratePerSecond); // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); @@ -313,8 +340,8 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope external override noDelegateCall - notCanceled(streamId) notNull(streamId) + notCanceled(streamId) onlySender(streamId) { // Checks, Effects and Interactions: make the refund. @@ -322,7 +349,17 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope } /// @inheritdoc ISablierV2OpenEnded - function withdrawAt(uint256 streamId, address to, uint40 time) external override { + function withdrawAt( + uint256 streamId, + address to, + uint40 time + ) + public + override + noDelegateCall + notNull(streamId) + updateMetadata(streamId) + { // Checks, Effects and Interactions: make the withdrawal. _withdrawAt(streamId, to, time); } @@ -346,14 +383,26 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope // 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. - _withdrawAt({ streamId: streamIds[i], to: _streams[streamIds[i]].recipient, time: times[i] }); + withdrawAt({ streamId: streamIds[i], to: _ownerOf(streamIds[i]), time: times[i] }); } } /// @inheritdoc ISablierV2OpenEnded function withdrawMax(uint256 streamId, address to) external override { // Checks, Effects and Interactions: make the withdrawal. - _withdrawAt(streamId, to, uint40(block.timestamp)); + withdrawAt(streamId, to, uint40(block.timestamp)); + } + + /// @inheritdoc ISablierV2OpenEnded + function withdrawMaxMultiple(uint256[] calldata streamIds) external override { + uint256 streamIdsCount = streamIds.length; + uint40 blockTimestamp = uint40(block.timestamp); + + // 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. + withdrawAt({ streamId: streamIds[i], to: _ownerOf(streamIds[i]), time: blockTimestamp }); + } } /*////////////////////////////////////////////////////////////////////////// @@ -375,7 +424,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope // Retrieve the asset's decimals from storage. uint8 assetDecimals = _streams[streamId].assetDecimals; - // Return the original amount if it's already in the standard 18-decimal format. + // Return the original amount if it's already in the 18-decimal format. if (assetDecimals == 18) { return amount; } @@ -432,14 +481,13 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope uint128 elapsedTime = time - lastTimeUpdate; // Calculate the streamed amount by multiplying the elapsed time by the rate per second. - uint128 ratePerSecond = _streams[streamId].ratePerSecond; - uint128 streamedAmount = elapsedTime * ratePerSecond; + uint128 streamedAmount = elapsedTime * _streams[streamId].ratePerSecond; return streamedAmount; } } - /// @dev Calculates the withdrawable amount. + /// @dev Calculates the withdrawable amount without looking at the stream's remaining amount. function _withdrawableAmountOf(uint256 streamId, uint40 time) internal view returns (uint128) { uint128 balance = _streams[streamId].balance; @@ -477,9 +525,11 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope uint128 recipientAmount = _withdrawableAmountOf(streamId, uint40(block.timestamp)); - // Although the withdrawable amount should never exceed the balance, this condition is checked to avoid exploits - // in case of a bug. - _checkCalculatedAmount(streamId, recipientAmount); + // Effect: sum up the remaining amount that the recipient is able to withdraw. + _streams[streamId].remainingAmount += recipientAmount; + + // Effect: subtract the recipient amount from the stream balance. + _streams[streamId].balance -= recipientAmount; // Effect: change the rate per second. _streams[streamId].ratePerSecond = newRatePerSecond; @@ -487,23 +537,16 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope // Effect: update the stream time. _updateTime(streamId, uint40(block.timestamp)); - // Effects and Interactions: withdraw the assets to the recipient, if any assets available. - if (recipientAmount > 0) { - _extractFromStream(streamId, _streams[streamId].recipient, recipientAmount); - } - // Log the adjustment. - emit ISablierV2OpenEnded.AdjustOpenEndedStream( - streamId, _streams[streamId].asset, recipientAmount, oldRatePerSecond, newRatePerSecond - ); + emit ISablierV2OpenEnded.AdjustOpenEndedStream(streamId, recipientAmount, oldRatePerSecond, newRatePerSecond); } /// @dev See the documentation for the user-facing functions that call this internal function. function _cancel(uint256 streamId) internal { - address recipient = _streams[streamId].recipient; - address sender = _streams[streamId].sender; uint128 balance = _streams[streamId].balance; + address recipient = _ownerOf(streamId); uint128 recipientAmount = _withdrawableAmountOf(streamId, uint40(block.timestamp)); + address sender = _streams[streamId].sender; // Calculate the refundable amount here for gas optimization. uint128 senderAmount = balance - recipientAmount; @@ -521,16 +564,17 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope // Effect: set the rate per second to zero. _streams[streamId].ratePerSecond = 0; - // Effects and Interactions: refund the sender, if any assets available. + // Effect: sum up the remaining amount that the recipient is able to withdraw. + _streams[streamId].remainingAmount += recipientAmount; + + // Effect: set the stream balance to zero. + _streams[streamId].balance = 0; + + // Interaction: perform the ERC-20 transfer, if any assets available. if (senderAmount > 0) { _extractFromStream(streamId, sender, senderAmount); } - // Effects and Interactions: withdraw the assets to the recipient, if any assets available. - if (recipientAmount > 0) { - _extractFromStream(streamId, recipient, recipientAmount); - } - // Log the cancellation. emit ISablierV2OpenEnded.CancelOpenEndedStream( streamId, sender, recipient, _streams[streamId].asset, senderAmount, recipientAmount @@ -542,10 +586,10 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope address sender, address recipient, uint128 ratePerSecond, - IERC20 asset + IERC20 asset, + bool isTransferable ) internal - noDelegateCall returns (uint256 streamId) { // Check: the sender is not the zero address. @@ -553,11 +597,6 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope revert Errors.SablierV2OpenEnded_SenderZeroAddress(); } - // Check: the recipient is not the zero address. - if (recipient == address(0)) { - revert Errors.SablierV2OpenEnded_RecipientZeroAddress(); - } - // Check: the rate per second is not zero. if (ratePerSecond == 0) { revert Errors.SablierV2OpenEnded_RatePerSecondZero(); @@ -580,9 +619,10 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope balance: 0, isCanceled: false, isStream: true, + isTransferable: isTransferable, lastTimeUpdate: uint40(block.timestamp), ratePerSecond: ratePerSecond, - recipient: recipient, + remainingAmount: 0, sender: sender }); @@ -592,6 +632,9 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope nextStreamId = streamId + 1; } + // Effect: mint the NFT to the recipient. + _mint({ to: recipient, tokenId: streamId }); + // Log the newly created stream. emit ISablierV2OpenEnded.CreateOpenEndedStream( streamId, sender, recipient, ratePerSecond, asset, uint40(block.timestamp) @@ -621,11 +664,8 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope emit ISablierV2OpenEnded.DepositOpenEndedStream(streamId, msg.sender, asset, amount); } - /// @dev Helper function to update the `balance` and to perform the ERC-20 transfer. + /// @dev Helper function to calculate the transfer amount and to perform the ERC-20 transfer. function _extractFromStream(uint256 streamId, address to, uint128 amount) internal { - // Effect: update the stream balance. - _streams[streamId].balance -= amount; - // Calculate the transfer amount. uint128 transferAmount = _calculateTransferAmount(streamId, amount); @@ -643,12 +683,19 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope revert Errors.SablierV2OpenEnded_RefundAmountZero(); } - // Check: the withdraw amount is not greater than the refundable amount. + // Check: the refund amount is not greater than the refundable amount. if (amount > refundableAmount) { revert Errors.SablierV2OpenEnded_Overrefund(streamId, amount, refundableAmount); } - // Effects and interactions: update the `balance` and perform the ERC-20 transfer. + // Although the refund amount should never exceed the available amount in stream, this condition is checked to + // avoid exploits in case of a bug. + _checkCalculatedAmount(streamId, amount); + + // Effect: update the stream balance. + _streams[streamId].balance -= amount; + + // Interaction: perform the ERC-20 transfer. _extractFromStream(streamId, sender, amount); // Log the refund. @@ -656,15 +703,7 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope } /// @dev See the documentation for the user-facing functions that call this internal function. - function _restartStream( - uint256 streamId, - uint128 ratePerSecond - ) - internal - noDelegateCall - notNull(streamId) - onlySender(streamId) - { + function _restartStream(uint256 streamId, uint128 ratePerSecond) internal { // Check: the stream is canceled. if (!_streams[streamId].isCanceled) { revert Errors.SablierV2OpenEnded_StreamNotCanceled(streamId); @@ -694,60 +733,65 @@ contract SablierV2OpenEnded is ISablierV2OpenEnded, NoDelegateCall, SablierV2Ope } /// @dev See the documentation for the user-facing functions that call this internal function. - function _withdrawAt( - uint256 streamId, - address to, - uint40 time - ) - internal - noDelegateCall - notCanceled(streamId) - notNull(streamId) - { + function _withdrawAt(uint256 streamId, address to, uint40 time) internal { // Check: the withdrawal address is not zero. if (to == address(0)) { revert Errors.SablierV2OpenEnded_WithdrawToZeroAddress(); } // Retrieve the recipient from storage. - address recipient = _streams[streamId].recipient; + address recipient = _ownerOf(streamId); - // Check: if `msg.sender` is not the stream's recipient, the withdrawal address must be the recipient. - if (to != recipient && msg.sender != recipient) { + // Check: if `msg.sender` is neither the stream's recipient nor an approved third party, the withdrawal address + // must be the recipient. + if (to != recipient && !_isCallerStreamRecipientOrApproved(streamId)) { revert Errors.SablierV2OpenEnded_WithdrawalAddressNotRecipient(streamId, msg.sender, to); } + // Retrieve the last time update from storage. uint40 lastTimeUpdate = _streams[streamId].lastTimeUpdate; - // Check: the withdrawal time is greater than the `lastTimeUpdate`. - if (time <= lastTimeUpdate) { - revert Errors.SablierV2OpenEnded_WithdrawalTimeNotGreaterThanLastUpdate(time, lastTimeUpdate); + // Check: the `lastTimeUpdate` is less than withdrawal time. + if (time < lastTimeUpdate) { + revert Errors.SablierV2OpenEnded_LastUpdateNotLessThanWithdrawalTime(lastTimeUpdate, time); } - // Check: the time reference is not in the future. + // Check: the withdrawal time is not in the future. if (time > uint40(block.timestamp)) { revert Errors.SablierV2OpenEnded_WithdrawalTimeInTheFuture(time, block.timestamp); } - // Check: the stream balance is not zero. - if (_streams[streamId].balance == 0) { - revert Errors.SablierV2OpenEnded_WithdrawBalanceZero(streamId); + // Retrieve the remaining amount from storage. + uint128 remainingAmount = _streams[streamId].remainingAmount; + + // Check: the stream balance and the remaining amount are not zero. + if (_streams[streamId].balance == 0 && remainingAmount == 0) { + revert Errors.SablierV2OpenEnded_WithdrawNoFundsAvailable(streamId); } - // Calculate how much to withdraw based on the time reference. - uint128 withdrawAmount = _withdrawableAmountOf(streamId, time); + // Calculate the withdrawable amount. + uint128 withdrawableAmount = _withdrawableAmountOf(streamId, time); - // Although the withdraw amount should never exceed the balance, this condition is checked to avoid exploits - // in case of a bug. - _checkCalculatedAmount(streamId, withdrawAmount); + // Calculate the sum of the withdrawable amount and the remaining amount. + uint128 sum = withdrawableAmount + remainingAmount; + + // Although the withdraw amount should never exceed the available amount in stream, this condition is checked to + // avoid exploits in case of a bug. + _checkCalculatedAmount(streamId, withdrawableAmount); // Effect: update the stream time. _updateTime(streamId, time); - // Effects and interactions: update the `balance` and perform the ERC-20 transfer. - _extractFromStream(streamId, to, withdrawAmount); + // Effect: Set the remaining amount to zero. + _streams[streamId].remainingAmount = 0; + + // Effect: update the stream balance. + _streams[streamId].balance -= withdrawableAmount; + + // Interaction: perform the ERC-20 transfer. + _extractFromStream(streamId, to, sum); // Log the withdrawal. - emit ISablierV2OpenEnded.WithdrawFromOpenEndedStream(streamId, to, _streams[streamId].asset, withdrawAmount); + emit ISablierV2OpenEnded.WithdrawFromOpenEndedStream(streamId, to, _streams[streamId].asset, sum); } } diff --git a/src/abstracts/SablierV2OpenEndedState.sol b/src/abstracts/SablierV2OpenEndedState.sol index 6464fcf9..4e9dbf78 100644 --- a/src/abstracts/SablierV2OpenEndedState.sol +++ b/src/abstracts/SablierV2OpenEndedState.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later 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 { ISablierV2OpenEndedState } from "../interfaces/ISablierV2OpenEndedState.sol"; import { OpenEnded } from "../types/DataTypes.sol"; @@ -9,7 +11,11 @@ import { Errors } from "../libraries/Errors.sol"; /// @title SablierV2OpenEndedState /// @notice See the documentation in {ISablierV2OpenEndedState}. -abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { +abstract contract SablierV2OpenEndedState is + IERC4906, // 2 inherited components + ISablierV2OpenEndedState, // 3 inherited component + ERC721 // 6 inherited components +{ /*////////////////////////////////////////////////////////////////////////// STATE VARIABLES //////////////////////////////////////////////////////////////////////////*/ @@ -56,21 +62,16 @@ abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { _; } + /// @dev Emits an ERC-4906 event to trigger an update of the NFT metadata. + modifier updateMetadata(uint256 streamId) { + _; + emit MetadataUpdate({ _tokenId: streamId }); + } + /*////////////////////////////////////////////////////////////////////////// CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - /// @inheritdoc ISablierV2OpenEndedState - function getRatePerSecond(uint256 streamId) - external - view - override - notNull(streamId) - returns (uint128 ratePerSecond) - { - ratePerSecond = _streams[streamId].ratePerSecond; - } - /// @inheritdoc ISablierV2OpenEndedState function getAsset(uint256 streamId) external view override notNull(streamId) returns (IERC20 asset) { asset = _streams[streamId].asset; @@ -103,9 +104,31 @@ abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { lastTimeUpdate = _streams[streamId].lastTimeUpdate; } + /// @inheritdoc ISablierV2OpenEndedState + function getRatePerSecond(uint256 streamId) + external + view + override + notNull(streamId) + returns (uint128 ratePerSecond) + { + ratePerSecond = _streams[streamId].ratePerSecond; + } + /// @inheritdoc ISablierV2OpenEndedState function getRecipient(uint256 streamId) external view override notNull(streamId) returns (address recipient) { - recipient = _streams[streamId].recipient; + recipient = _ownerOf(streamId); + } + + /// @inheritdoc ISablierV2OpenEndedState + function getRemainingAmount(uint256 streamId) + external + view + override + notNull(streamId) + returns (uint128 remainingAmount) + { + remainingAmount = _streams[streamId].remainingAmount; } /// @inheritdoc ISablierV2OpenEndedState @@ -133,4 +156,48 @@ abstract contract SablierV2OpenEndedState is ISablierV2OpenEndedState { 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 recipient or an approved third party. + /// @param streamId The stream ID for the query. + function _isCallerStreamRecipientOrApproved(uint256 streamId) internal view returns (bool) { + address recipient = _ownerOf(streamId); + return msg.sender == recipient || isApprovedForAll({ owner: recipient, operator: msg.sender }) + || getApproved(streamId) == msg.sender; + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Overrides the {ERC-721._update} function to check that the stream is transferable. + /// @dev The transferable flag is ignored if the current owner is 0, as the update in this case is a mint and + /// is allowed. Transfers to the zero address are not allowed, preventing accidental burns. + /// + /// @param to The address of the new recipient of the stream. + /// @param streamId ID of the stream to update. + /// @param auth Optional parameter. If the value is not zero, the overridden implementation will check that + /// `auth` is either the recipient of the stream, or an approved third party. + /// @return The original recipient of the `streamId` before the update. + function _update( + address to, + uint256 streamId, + address auth + ) + internal + override + updateMetadata(streamId) + returns (address) + { + address from = _ownerOf(streamId); + + if (from != address(0) && !_streams[streamId].isTransferable) { + revert Errors.SablierV2OpenEndedState_NotTransferable(streamId); + } + + return super._update(to, streamId, auth); + } } diff --git a/src/interfaces/ISablierV2OpenEnded.sol b/src/interfaces/ISablierV2OpenEnded.sol index 757683a1..f2b1a1ed 100644 --- a/src/interfaces/ISablierV2OpenEnded.sol +++ b/src/interfaces/ISablierV2OpenEnded.sol @@ -7,22 +7,20 @@ import { ISablierV2OpenEndedState } from "./ISablierV2OpenEndedState.sol"; /// @title ISablierV2OpenEnded /// @notice Creates and manages Open Ended streams with linear streaming functions. -interface ISablierV2OpenEnded is ISablierV2OpenEndedState { +interface ISablierV2OpenEnded is + ISablierV2OpenEndedState // 3 inherited component +{ /*////////////////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////////////////*/ /// @notice Emitted when the sender changes the rate per second. /// @param streamId The ID of the stream. - /// @param recipientAmount The amount of assets withdrawn to the recipient, denoted in 18 decimals. + /// @param recipientAmount The amount of assets that the recipient is able to withdraw, denoted in 18 decimals. /// @param oldRatePerSecond The rate per second to change. /// @param newRatePerSecond The newly changed rate per second. event AdjustOpenEndedStream( - uint256 indexed streamId, - IERC20 indexed asset, - uint128 recipientAmount, - uint128 oldRatePerSecond, - uint128 newRatePerSecond + uint256 indexed streamId, uint128 recipientAmount, uint128 oldRatePerSecond, uint128 newRatePerSecond ); /// @notice Emitted when a open-ended stream is canceled. @@ -89,9 +87,9 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @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 withdrawAmount The amount of assets withdrawn, denoted in 18 decimals. + /// @param withdrawnAmount The amount of assets withdrawn, denoted in 18 decimals. event WithdrawFromOpenEndedStream( - uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 withdrawAmount + uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 withdrawnAmount ); /*////////////////////////////////////////////////////////////////////////// @@ -131,12 +129,12 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { 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 or a null stream. + /// @dev Reverts if `streamId` references 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 or a null stream. + /// @dev Reverts if `streamId` references 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); @@ -150,7 +148,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @dev Emits a {Transfer} and {AdjustOpenEndedStream} event. /// /// Notes: - /// - The streamed assets, until the adjustment moment, must be transferred to the recipient. + /// - The streamed assets, until the adjustment moment, will be summed up to the remaining amount. /// - This function updates stream's `lastTimeUpdate` to the current block timestamp. /// /// Requiremenets: @@ -163,7 +161,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @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 available assets to the sender and recipient. + /// @notice Cancels the stream and refunds available assets to the sender. /// /// @dev Emits a {Transfer} and {CancelOpenEndedStream} event. /// @@ -175,7 +173,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @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. + /// @notice Cancels multiple streams and refunds available assets to the sender. /// /// @dev Emits multiple {Transfer} and {CancelOpenEndedStream} events. /// @@ -186,6 +184,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { 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. + /// The stream is wrapped in an ERC-721 NFT. /// /// @dev Emits a {CreateOpenEndedStream} event. /// @@ -201,18 +200,20 @@ 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 isTransferable Boolean indicating if the stream NFT is transferable. /// @return streamId The ID of the newly created stream. function create( address recipient, address sender, uint128 ratePerSecond, - IERC20 asset + IERC20 asset, + bool isTransferable ) external returns (uint256 streamId); /// @notice Creates a new open-ended stream with the `block.timestamp` as the time reference - /// and with `amount` balance. + /// and with `amount` balance. The stream is wrapped in an ERC-721 NFT. /// /// @dev Emits a {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. /// @@ -225,6 +226,7 @@ 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 isTransferable Boolean indicating if the stream NFT is transferable. /// @param amount The amount deposited in the stream. /// @return streamId The ID of the newly created stream. function createAndDeposit( @@ -232,13 +234,14 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { address sender, uint128 ratePerSecond, IERC20 asset, + bool isTransferable, uint128 amount ) external returns (uint256 streamId); /// @notice Creates multiple open-ended streams with the `block.timestamp` as the time reference and with - /// `amounts` balances. + /// `amounts` balances. The streams are wrapped in ERC-721 NFTs. /// /// @dev Emits multiple {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. /// @@ -250,6 +253,7 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @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 isTransferable An array of booleans indicating if the stream NFT is transferable. /// @param amounts The amounts deposited in the streams. /// @return streamIds The IDs of the newly created streams. function createAndDepositMultiple( @@ -257,13 +261,14 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { address[] calldata senders, uint128[] calldata ratesPerSecond, IERC20 asset, + bool[] calldata isTransferable, 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. + /// balance. The stream is wrapped in an ERC-721 NFT. /// /// @dev Emits multiple {CreateOpenEndedStream} events. /// @@ -275,11 +280,13 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @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 isTransferable An array of booleans indicating if the stream NFTs are transferable. function createMultiple( address[] calldata recipients, address[] calldata senders, uint128[] calldata ratesPerSecond, - IERC20 asset + IERC20 asset, + bool[] calldata isTransferable ) external returns (uint256[] memory streamIds); @@ -352,14 +359,14 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @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. + /// @notice Withdraws the amount of assets calculated based on time reference and the remaining amount, from the + /// stream to the provided `to` address. /// /// @dev Emits a {Transfer} and {WithdrawFromOpenEndedStream} event. /// /// Requirements: /// - Must not be delegate called. - /// - `streamId` must not reference a null or canceled stream. + /// - `streamId` must not reference a null stream. /// - `to` must not be the zero address. /// - `to` must be the recipient if `msg.sender` is not the stream's recipient. /// - `time` must be greater than the stream's `lastTimeUpdate` and must not be in the future. @@ -370,28 +377,38 @@ interface ISablierV2OpenEnded is ISablierV2OpenEndedState { /// @param time The Unix timestamp for the streamed amount calculation. function withdrawAt(uint256 streamId, address to, uint40 time) 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 withdrawAtMultiple(uint256[] calldata streamIds, uint40[] calldata times) external; + /// @notice Withdraws the maximum withdrawable amount from the stream to the provided address `to`. /// /// @dev Emits a {Transfer}, {WithdrawFromOpenEndedStream} event. /// /// Requirements: - /// - Refer to the requirements in {withdraw}. + /// - Refer to the requirements in {withdrawAt}. /// /// @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. + /// @notice Withdraws the maximum withdrawable amount from each stream 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`. + /// - All requirements from {withdrawAt} must be met for each stream. /// /// @param streamIds The IDs of the streams to withdraw from. - /// @param times The time references to calculate the streamed amount for each stream. - function withdrawAtMultiple(uint256[] calldata streamIds, uint40[] calldata times) external; + function withdrawMaxMultiple(uint256[] calldata streamIds) external; } diff --git a/src/interfaces/ISablierV2OpenEndedState.sol b/src/interfaces/ISablierV2OpenEndedState.sol index dd60c3e5..97287d19 100644 --- a/src/interfaces/ISablierV2OpenEndedState.sol +++ b/src/interfaces/ISablierV2OpenEndedState.sol @@ -2,13 +2,16 @@ 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 { OpenEnded } from "../types/DataTypes.sol"; /// @title ISablierV2OpenEndedState /// @notice State variables, storage and constants, for the {SablierV2OpenEnded} contract, and their respective getters. -/// @dev This contract includes helpful modifiers and helper functions. -interface ISablierV2OpenEndedState { +/// @dev This contract also includes helpful modifiers and helper functions. +interface ISablierV2OpenEndedState is + IERC721Metadata // 2 inherited components +{ /*////////////////////////////////////////////////////////////////////////// CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ @@ -44,6 +47,11 @@ interface ISablierV2OpenEndedState { /// @param streamId The stream ID for the query. function getRecipient(uint256 streamId) external view returns (address recipient); + /// @notice Retrieves the remaining amount of the stream, denoted in 18 decimals. + /// @dev Reverts if `streamId` references a null stream. + /// @param streamId The stream ID for the query. + function getRemainingAmount(uint256 streamId) external view returns (uint128 remainingAmount); + /// @notice Retrieves the stream's sender. /// @dev Reverts if `streamId` references a null stream. /// @param streamId The stream ID for the query. diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index a6cdde02..f4d3aa89 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -34,7 +34,14 @@ library Errors { error SablierV2OpenEnded_InvalidAssetDecimals(IERC20 asset); /// @notice Thrown when an unexpected error occurs during the calculation of an amount. - error SablierV2OpenEnded_InvalidCalculation(uint256 streamId, uint128 balance, uint128 amount); + error SablierV2OpenEnded_InvalidCalculation(uint256 streamId, uint128 availableAmount, uint128 amount); + + /// @notice Thrown when trying to withdraw assets with a withdrawal time not greater than or equal to + /// `lastTimeUpdate`. + error SablierV2OpenEnded_LastUpdateNotLessThanWithdrawalTime(uint40 lastUpdate, uint40 time); + + /// @notice Thrown when trying to transfer Stream NFT when transferability is disabled. + error SablierV2OpenEndedState_NotTransferable(uint256 streamId); /// @notice Thrown when the ID references a null stream. error SablierV2OpenEnded_Null(uint256 streamId); @@ -48,9 +55,6 @@ library Errors { /// @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(); - /// @notice Thrown when trying to refund zero assets from a stream. error SablierV2OpenEnded_RefundAmountZero(); @@ -69,15 +73,12 @@ library Errors { /// @notice Thrown when trying to withdraw to an address other than the recipient's. error SablierV2OpenEnded_WithdrawalAddressNotRecipient(uint256 streamId, address caller, address to); + /// @notice Thrown when trying to withdraw but the stream no funds available. + error SablierV2OpenEnded_WithdrawNoFundsAvailable(uint256 streamId); + /// @notice Thrown when trying to withdraw assets with a withdrawal time in the future. error SablierV2OpenEnded_WithdrawalTimeInTheFuture(uint40 time, uint256 currentTime); - /// @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 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); diff --git a/src/types/DataTypes.sol b/src/types/DataTypes.sol index 376fd386..926f7d16 100644 --- a/src/types/DataTypes.sol +++ b/src/types/DataTypes.sol @@ -11,26 +11,29 @@ library OpenEnded { /// @param balance The amount of assets that is currently available in the stream, i.e. the sum of deposited amounts /// subtracted by the sum of withdrawn amounts, denoted in 18 decimals. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. - /// @param recipient The address receiving the assets. + /// @param sender The address streaming the assets, with the ability to cancel the stream. /// @param lastTimeUpdate The Unix timestamp for the streamed amount calculation. /// @param isStream Boolean indicating if the struct entity exists. /// @param isCanceled Boolean indicating if the stream is canceled. + /// @param isTransferable Boolean indicating if the stream NFT is transferable. /// @param asset The contract address of the ERC-20 asset used for streaming. /// @param assetDecimals The decimals of the ERC-20 asset used for streaming. - /// @param sender The address streaming the assets, with the ability to cancel the stream. + /// @param remainingAmount The amount of assets still available for withdrawal, when the stream is canceled or the + /// `ratePerSecond` is adjusted, denoted in 18 decimals. struct Stream { // slot 0 uint128 balance; uint128 ratePerSecond; // slot 1 - address recipient; + address sender; uint40 lastTimeUpdate; bool isStream; bool isCanceled; + bool isTransferable; // slot 2 IERC20 asset; uint8 assetDecimals; // slot 3 - address sender; + uint128 remainingAmount; } } diff --git a/test/Base.t.sol b/test/Base.t.sol index bd5feb99..13dca2ac 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -27,6 +27,7 @@ abstract contract Base_Test is Assertions, Events, Modifiers, Test, Utils { DEFAULTS //////////////////////////////////////////////////////////////////////////*/ + bool public constant IS_TRANFERABLE = true; uint128 public constant RATE_PER_SECOND = 0.001e18; // 86.4 daily uint128 public constant DEPOSIT_AMOUNT = 50_000e18; uint40 internal constant MAY_1_2024 = 1_714_518_000; diff --git a/test/integration/Integration.t.sol b/test/integration/Integration.t.sol index 58e88a55..d7cdaea6 100644 --- a/test/integration/Integration.t.sol +++ b/test/integration/Integration.t.sol @@ -12,6 +12,7 @@ abstract contract Integration_Test is Base_Test { uint128[] internal defaultRatesPerSecond; address[] internal defaultRecipients; address[] internal defaultSenders; + bool[] internal defaultIsTransferable; uint256 internal defaultStreamId; uint256[] internal defaultStreamIds; uint256 internal nullStreamId = 420; @@ -29,6 +30,7 @@ abstract contract Integration_Test is Base_Test { defaultSenders.push(users.sender); defaultRatesPerSecond.push(RATE_PER_SECOND); defaultDepositAmounts.push(DEPOSIT_AMOUNT); + defaultIsTransferable.push(IS_TRANFERABLE); } } @@ -45,7 +47,8 @@ abstract contract Integration_Test is Base_Test { sender: users.sender, recipient: users.recipient, ratePerSecond: RATE_PER_SECOND, - asset: asset_ + asset: asset_, + isTransferable: IS_TRANFERABLE }); } diff --git a/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol b/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol index 9baaf77e..5ca7deaa 100644 --- a/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol +++ b/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol @@ -1,14 +1,12 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.22; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - import { ISablierV2OpenEnded } from "src/interfaces/ISablierV2OpenEnded.sol"; import { Errors } from "src/libraries/Errors.sol"; import { Integration_Test } from "../Integration.t.sol"; -contract adjustRatePerSecond_Integration_Test is Integration_Test { +contract AdjustRatePerSecond_Integration_Test is Integration_Test { function setUp() public override { Integration_Test.setUp(); } @@ -74,7 +72,7 @@ contract adjustRatePerSecond_Integration_Test is Integration_Test { givenNotNull givenNotCanceled whenCallerAuthorized - whenratePerSecondNonZero + whenRatePerSecondNonZero { vm.expectRevert( abi.encodeWithSelector(Errors.SablierV2OpenEnded_RatePerSecondNotDifferent.selector, RATE_PER_SECOND) @@ -82,21 +80,17 @@ contract adjustRatePerSecond_Integration_Test is Integration_Test { openEnded.adjustRatePerSecond({ streamId: defaultStreamId, newRatePerSecond: RATE_PER_SECOND }); } - function test_adjustRatePerSecond_WithdrawableAmountZero() + function test_AdjustRatePerSecond_WithdrawableAmountZero() external whenNotDelegateCalled givenNotNull givenNotCanceled whenCallerAuthorized - whenratePerSecondNonZero - whenratePerSecondNotDifferent + whenRatePerSecondNonZero + whenRatePerSecondNotDifferent { vm.warp({ newTimestamp: WARP_ONE_MONTH }); - uint128 actualratePerSecond = openEnded.getRatePerSecond(defaultStreamId); - uint128 expectedratePerSecond = RATE_PER_SECOND; - assertEq(actualratePerSecond, expectedratePerSecond, "rate per second"); - uint40 actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamId); uint40 expectedLastTimeUpdate = uint40(block.timestamp - ONE_MONTH); assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); @@ -106,87 +100,91 @@ contract adjustRatePerSecond_Integration_Test is Integration_Test { vm.expectEmit({ emitter: address(openEnded) }); emit AdjustOpenEndedStream({ streamId: defaultStreamId, - asset: dai, recipientAmount: 0, oldRatePerSecond: RATE_PER_SECOND, newRatePerSecond: newRatePerSecond }); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: defaultStreamId }); + + uint128 actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + uint128 actualStreamBalance = openEnded.getBalance(defaultStreamId); + + assertEq(actualRemainingAmount, 0, "remaining amount"); + assertEq(actualStreamBalance, 0, "stream balance"); + openEnded.adjustRatePerSecond({ streamId: defaultStreamId, newRatePerSecond: newRatePerSecond }); - actualratePerSecond = openEnded.getRatePerSecond(defaultStreamId); - expectedratePerSecond = newRatePerSecond; - assertEq(actualratePerSecond, expectedratePerSecond, "rate per second"); + actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + actualStreamBalance = openEnded.getBalance(defaultStreamId); + + assertEq(actualRemainingAmount, 0, "remaining amount"); + assertEq(actualStreamBalance, 0, "stream balance"); + + uint128 actualRatePerSecond = openEnded.getRatePerSecond(defaultStreamId); + uint128 expectedRatePerSecond = newRatePerSecond; + assertEq(actualRatePerSecond, expectedRatePerSecond, "rate per second"); actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamId); expectedLastTimeUpdate = uint40(block.timestamp); assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); } - function test_adjustRatePerSecond_AssetNot18Decimals() - external - whenNotDelegateCalled - givenNotNull - givenNotCanceled - whenCallerAuthorized - { - uint256 streamId = createDefaultStreamWithAsset(IERC20(address(usdt))); - test_adjustRatePerSecond(streamId, IERC20(address(usdt))); - } - - function test_adjustRatePerSecond() + function test_AdjustRatePerSecond() external whenNotDelegateCalled givenNotNull givenNotCanceled whenCallerAuthorized { - test_adjustRatePerSecond(defaultStreamId, dai); - } - - function test_adjustRatePerSecond(uint256 streamId, IERC20 asset) internal { - openEnded.deposit(streamId, DEPOSIT_AMOUNT); + openEnded.deposit(defaultStreamId, DEPOSIT_AMOUNT); vm.warp({ newTimestamp: WARP_ONE_MONTH }); - uint128 actualratePerSecond = openEnded.getRatePerSecond(streamId); - uint128 expectedratePerSecond = RATE_PER_SECOND; - assertEq(actualratePerSecond, expectedratePerSecond, "rate per second"); + uint128 actualRatePerSecond = openEnded.getRatePerSecond(defaultStreamId); + uint128 expectedRatePerSecond = RATE_PER_SECOND; + assertEq(actualRatePerSecond, expectedRatePerSecond, "rate per second"); - uint40 actualLastTimeUpdate = openEnded.getLastTimeUpdate(streamId); + uint40 actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamId); uint40 expectedLastTimeUpdate = uint40(block.timestamp - ONE_MONTH); assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); - vm.expectEmit({ emitter: address(asset) }); - emit Transfer({ - from: address(openEnded), - to: users.recipient, - value: normalizeTransferAmount(streamId, ONE_MONTH_STREAMED_AMOUNT) - }); + uint128 actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + uint128 expectedRemainingAmount = 0; + assertEq(actualRemainingAmount, expectedRemainingAmount, "remaining amount"); + + uint128 actualStreamBalance = openEnded.getBalance(defaultStreamId); + uint128 expectedStreamBalance = DEPOSIT_AMOUNT; + assertEq(actualStreamBalance, DEPOSIT_AMOUNT, "stream balance"); uint128 newRatePerSecond = RATE_PER_SECOND / 2; vm.expectEmit({ emitter: address(openEnded) }); emit AdjustOpenEndedStream({ - streamId: streamId, - asset: asset, + streamId: defaultStreamId, recipientAmount: ONE_MONTH_STREAMED_AMOUNT, oldRatePerSecond: RATE_PER_SECOND, newRatePerSecond: newRatePerSecond }); - expectCallToTransfer({ - asset: asset, - to: users.recipient, - amount: normalizeTransferAmount(streamId, ONE_MONTH_STREAMED_AMOUNT) - }); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: defaultStreamId }); - openEnded.adjustRatePerSecond({ streamId: streamId, newRatePerSecond: newRatePerSecond }); + openEnded.adjustRatePerSecond({ streamId: defaultStreamId, newRatePerSecond: newRatePerSecond }); - actualratePerSecond = openEnded.getRatePerSecond(streamId); - expectedratePerSecond = newRatePerSecond; - assertEq(actualratePerSecond, expectedratePerSecond, "rate per second"); + actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + expectedRemainingAmount = ONE_MONTH_STREAMED_AMOUNT; + assertEq(actualRemainingAmount, expectedRemainingAmount, "remaining amount"); - actualLastTimeUpdate = openEnded.getLastTimeUpdate(streamId); + actualStreamBalance = openEnded.getBalance(defaultStreamId); + expectedStreamBalance = DEPOSIT_AMOUNT - ONE_MONTH_STREAMED_AMOUNT; + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + + actualRatePerSecond = openEnded.getRatePerSecond(defaultStreamId); + expectedRatePerSecond = newRatePerSecond; + assertEq(actualRatePerSecond, expectedRatePerSecond, "rate per second"); + + actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamId); expectedLastTimeUpdate = uint40(block.timestamp); assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); } diff --git a/test/integration/adjust-rate-per-second/adjustRatePerSecond.tree b/test/integration/adjust-rate-per-second/adjustRatePerSecond.tree index 355bbd16..d4351e8c 100644 --- a/test/integration/adjust-rate-per-second/adjustRatePerSecond.tree +++ b/test/integration/adjust-rate-per-second/adjustRatePerSecond.tree @@ -21,19 +21,15 @@ adjustRatePerSecond.t.sol │ └── it should revert └── when the provided rate per second is not equal to the actual rate per second ├── given the withdrawable amount is zero + │ ├── it should update the stream balance │ ├── it should update the rate per second │ ├── it should update the stream time - │ └── it should emit a {AdjustOpenEndedStream} event + │ ├── it should emit a {AdjustOpenEndedStream} event + │ └── it should emit a {MetadataUpdate} event └── given the withdrawable amount is not zero - ├── given the asset does not have 18 decimals - │ ├── it should update the rate per second - │ ├── it should update the stream time - │ ├── it should update the stream balance - │ ├── it should perform the ERC-20 transfer - │ └── it should emit a {Transfer} and {AdjustOpenEndedStream} event - └── given the asset has 18 decimals - ├── it should make the withdrawal - ├── it should update the time - ├── it should update the stream balance - ├── it should perform the ERC-20 transfer - └── it should emit a {Transfer} and {AdjustOpenEndedStream} event \ No newline at end of file + ├── it should update the stream remaining amount + ├── it should update the stream balance + ├── it should update the rate per second + ├── it should update the stream time + ├── it should emit a {AdjustOpenEndedStream} event + └── it should emit a {MetadataUpdate} event \ No newline at end of file diff --git a/test/integration/cancel-multiple/cancelMultiple.t.sol b/test/integration/cancel-multiple/cancelMultiple.t.sol index 743ec0bd..8afca34f 100644 --- a/test/integration/cancel-multiple/cancelMultiple.t.sol +++ b/test/integration/cancel-multiple/cancelMultiple.t.sol @@ -73,7 +73,8 @@ contract CancelMultiple_Integration_Concrete_Test is Integration_Test { sender: users.eve, recipient: users.recipient, ratePerSecond: RATE_PER_SECOND, - asset: dai + asset: dai, + isTransferable: IS_TRANFERABLE }); resetPrank({ msgSender: users.eve }); @@ -95,7 +96,8 @@ contract CancelMultiple_Integration_Concrete_Test is Integration_Test { sender: users.recipient, recipient: users.recipient, ratePerSecond: RATE_PER_SECOND, - asset: dai + asset: dai, + isTransferable: IS_TRANFERABLE }); resetPrank({ msgSender: users.recipient }); diff --git a/test/integration/cancel/cancel.t.sol b/test/integration/cancel/cancel.t.sol index bc209e22..a84de672 100644 --- a/test/integration/cancel/cancel.t.sol +++ b/test/integration/cancel/cancel.t.sol @@ -58,22 +58,56 @@ contract Cancel_Integration_Test is Integration_Test { openEnded.cancel(defaultStreamId); } - function test_Cancel_RefundableAmountZero_WithdrawableAmountZero() + function test_Cancel_WithdrawableAmountZero() external whenNotDelegateCalled givenNotNull givenNotCanceled whenCallerAuthorized { + assertEq(openEnded.refundableAmountOf(defaultStreamId), 0, "refundable amount before cancel"); + assertEq(openEnded.withdrawableAmountOf(defaultStreamId), 0, "withdrawable amount before cancel"); + openEnded.cancel(defaultStreamId); assertTrue(openEnded.isCanceled(defaultStreamId), "is canceled"); + uint128 actualRatePerSecond = openEnded.getRatePerSecond(defaultStreamId); + assertEq(actualRatePerSecond, 0, "rate per second"); + + uint128 actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + assertEq(actualRemainingAmount, 0, "remaining amount"); + uint128 actualStreamBalance = openEnded.getBalance(defaultStreamId); assertEq(actualStreamBalance, 0, "stream balance"); + } + + function test_Cancel_RefundableAmountZero() + external + whenNotDelegateCalled + givenNotNull + givenNotCanceled + whenCallerAuthorized + whenWithdrawableAmountNotZero + { + openEnded.deposit(defaultStreamId, WITHDRAW_AMOUNT); + + assertEq(openEnded.getBalance(defaultStreamId), WITHDRAW_AMOUNT, "balance before"); + assertEq(openEnded.withdrawableAmountOf(defaultStreamId), WITHDRAW_AMOUNT, "withdrawable amount before cancel"); + + openEnded.cancel(defaultStreamId); + + assertTrue(openEnded.isCanceled(defaultStreamId), "is canceled"); + + uint128 actualRatePerSecond = openEnded.getRatePerSecond(defaultStreamId); + assertEq(actualRatePerSecond, 0, "rate per second"); - uint256 actualratePerSecond = openEnded.getRatePerSecond(defaultStreamId); - assertEq(actualratePerSecond, 0, "rate per second"); + uint128 actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + uint128 expectedRemainingAmount = WITHDRAW_AMOUNT; + assertEq(actualRemainingAmount, expectedRemainingAmount, "remaining amount"); + + uint128 actualStreamBalance = openEnded.getBalance(defaultStreamId); + assertEq(actualStreamBalance, 0, "stream balance"); } function test_Cancel_AssetNot18Decimals() @@ -82,8 +116,8 @@ contract Cancel_Integration_Test is Integration_Test { givenNotNull givenNotCanceled whenCallerAuthorized - whenRefundAmountNotZero - whenNoOverrefund + whenWithdrawableAmountNotZero + whenRefundableAmountNotZero { // Set the timestamp to 1 month ago to create the stream with the same `lastTimeUpdate` as `defaultStreamId`. vm.warp({ newTimestamp: WARP_ONE_MONTH - ONE_MONTH }); @@ -100,8 +134,8 @@ contract Cancel_Integration_Test is Integration_Test { givenNotNull givenNotCanceled whenCallerAuthorized - whenRefundAmountNotZero - whenNoOverrefund + whenWithdrawableAmountNotZero + whenRefundableAmountNotZero { test_Cancel(defaultStreamId, dai); } @@ -113,19 +147,12 @@ contract Cancel_Integration_Test is Integration_Test { uint128 withdrawableAmount = openEnded.withdrawableAmountOf(streamId); vm.expectEmit({ emitter: address(asset) }); - emit Transfer({ + emit IERC20.Transfer({ from: address(openEnded), to: users.sender, value: normalizeTransferAmount(streamId, refundableAmount) }); - vm.expectEmit({ emitter: address(asset) }); - emit Transfer({ - from: address(openEnded), - to: users.recipient, - value: normalizeTransferAmount(streamId, withdrawableAmount) - }); - vm.expectEmit({ emitter: address(openEnded) }); emit CancelOpenEndedStream({ streamId: streamId, @@ -136,24 +163,26 @@ contract Cancel_Integration_Test is Integration_Test { asset: asset }); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: streamId }); + expectCallToTransfer({ asset: asset, to: users.sender, amount: normalizeTransferAmount(streamId, refundableAmount) }); - expectCallToTransfer({ - asset: asset, - to: users.recipient, - amount: normalizeTransferAmount(streamId, withdrawableAmount) - }); + openEnded.cancel(streamId); assertTrue(openEnded.isCanceled(streamId), "is canceled"); + uint256 actualRatePerSecond = openEnded.getRatePerSecond(streamId); + assertEq(actualRatePerSecond, 0, "rate per second"); + + uint128 actualRemainingAmount = openEnded.getRemainingAmount(streamId); + assertEq(actualRemainingAmount, withdrawableAmount, "remaining amount"); + uint128 actualStreamBalance = openEnded.getBalance(streamId); assertEq(actualStreamBalance, 0, "stream balance"); - - uint256 actualratePerSecond = openEnded.getRatePerSecond(streamId); - assertEq(actualratePerSecond, 0, "rate per second"); } } diff --git a/test/integration/cancel/cancel.tree b/test/integration/cancel/cancel.tree index 714ab3e6..0d492ef4 100644 --- a/test/integration/cancel/cancel.tree +++ b/test/integration/cancel/cancel.tree @@ -14,20 +14,29 @@ cancel.t.sol │ └── when the caller is a malicious third party │ └── it should revert └── when the caller is authorized - ├── given the refundable amount and the withdrawable amounts are zero + ├── given the withdrawable amount is zero │ ├── it should cancel the stream - │ ├── it should update the rate per second - │ └── it should update the stream balance - └── given the refundable amount and the withdrawable amounts are not zero - ├── given the asset does not have 18 decimals + │ └── it should set the rate per second to zero + └── given the withdrawable amount is not zero + ├── given the refundable amount is zero │ ├── it should cancel the stream - │ ├── it should update the rate per second - │ ├── it should update the stream balance - │ ├── it should perform the ERC-20 transfers - │ └── it should emit two {Transfer} events and a {CancelOpenEndedStream} event - └── given the asset has 18 decimals - ├── it should cancel the stream - ├── it should update the rate per second - ├── it should update the stream balance - ├── it should perform the ERC-20 transfers - └── it should emit two {Transfer} events and a {CancelOpenEndedStream} event \ No newline at end of file + │ ├── it should set the rate per second to zero + │ ├── it should update the remaining amount + │ └── it should update the stream balance + └── given the refundable amount is not zero + ├── given the asset does not have 18 decimals + │ ├── it should cancel the stream + │ ├── it should set the rate per second to zero + │ ├── it should update the remaining amount + │ ├── it should update the stream balance + │ ├── it should perform the ERC-20 transfers + │ ├── it should emit a {Transfer} event and a {CancelOpenEndedStream} event + │ └── it should emit a {MetadataUpdate} event + └── given the asset has 18 decimals + ├── it should cancel the stream + ├── it should set the rate per second to zero + ├── it should update the remaining amount + ├── it should update the stream balance + ├── it should perform the ERC-20 transfers + ├── it should emit a {Transfer} event and a {CancelOpenEndedStream} event + └── it should emit a {MetadataUpdate} event \ No newline at end of file diff --git a/test/integration/create-and-deposit-multiple/createAndDepositMultiple.t.sol b/test/integration/create-and-deposit-multiple/createAndDepositMultiple.t.sol index 215ec324..c07e294a 100644 --- a/test/integration/create-and-deposit-multiple/createAndDepositMultiple.t.sol +++ b/test/integration/create-and-deposit-multiple/createAndDepositMultiple.t.sol @@ -22,7 +22,7 @@ contract CreateAndDepositMultiple_Integration_Test is Integration_Test { ) ); openEnded.createAndDepositMultiple( - defaultRecipients, defaultSenders, defaultRatesPerSecond, dai, depositAmounts + defaultRecipients, defaultSenders, defaultRatesPerSecond, dai, defaultIsTransferable, depositAmounts ); } @@ -68,7 +68,7 @@ contract CreateAndDepositMultiple_Integration_Test is Integration_Test { expectCallToTransferFrom({ asset: dai, from: users.sender, to: address(openEnded), amount: DEPOSIT_AMOUNT }); uint256[] memory streamIds = openEnded.createAndDepositMultiple( - defaultRecipients, defaultSenders, defaultRatesPerSecond, dai, defaultDepositAmounts + defaultRecipients, defaultSenders, defaultRatesPerSecond, dai, defaultIsTransferable, defaultDepositAmounts ); uint256 afterNextStreamId = openEnded.nextStreamId(); @@ -91,7 +91,8 @@ contract CreateAndDepositMultiple_Integration_Test is Integration_Test { lastTimeUpdate: uint40(block.timestamp), isCanceled: false, isStream: true, - recipient: users.recipient, + isTransferable: IS_TRANFERABLE, + remainingAmount: 0, sender: users.sender }); diff --git a/test/integration/create-multiple/createMultiple.t.sol b/test/integration/create-multiple/createMultiple.t.sol index 849fd8db..a83ec84b 100644 --- a/test/integration/create-multiple/createMultiple.t.sol +++ b/test/integration/create-multiple/createMultiple.t.sol @@ -22,7 +22,7 @@ contract CreateMultiple_Integration_Test is Integration_Test { defaultRatesPerSecond.length ) ); - openEnded.createMultiple(recipients, defaultSenders, defaultRatesPerSecond, dai); + openEnded.createMultiple(recipients, defaultSenders, defaultRatesPerSecond, dai, defaultIsTransferable); } function test_RevertWhen_SendersCountNotEqual() external whenNotDelegateCalled whenArrayCountsNotEqual { @@ -36,7 +36,7 @@ contract CreateMultiple_Integration_Test is Integration_Test { defaultRatesPerSecond.length ) ); - openEnded.createMultiple(defaultRecipients, senders, defaultRatesPerSecond, dai); + openEnded.createMultiple(defaultRecipients, senders, defaultRatesPerSecond, dai, defaultIsTransferable); } function test_RevertWhen_RatePerSecondCountNotEqual() external whenNotDelegateCalled whenArrayCountsNotEqual { @@ -50,12 +50,14 @@ contract CreateMultiple_Integration_Test is Integration_Test { ratesPerSecond.length ) ); - openEnded.createMultiple(defaultRecipients, defaultSenders, ratesPerSecond, dai); + openEnded.createMultiple(defaultRecipients, defaultSenders, ratesPerSecond, dai, defaultIsTransferable); } function test_CreateMultiple() external whenNotDelegateCalled whenArrayCountsEqual { uint256 beforeNextStreamId = openEnded.nextStreamId(); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: beforeNextStreamId }); vm.expectEmit({ emitter: address(openEnded) }); emit CreateOpenEndedStream({ streamId: beforeNextStreamId, @@ -65,6 +67,9 @@ contract CreateMultiple_Integration_Test is Integration_Test { asset: dai, lastTimeUpdate: uint40(block.timestamp) }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: beforeNextStreamId + 1 }); vm.expectEmit({ emitter: address(openEnded) }); emit CreateOpenEndedStream({ streamId: beforeNextStreamId + 1, @@ -75,8 +80,9 @@ contract CreateMultiple_Integration_Test is Integration_Test { lastTimeUpdate: uint40(block.timestamp) }); - uint256[] memory streamIds = - openEnded.createMultiple(defaultRecipients, defaultSenders, defaultRatesPerSecond, dai); + uint256[] memory streamIds = openEnded.createMultiple( + defaultRecipients, defaultSenders, defaultRatesPerSecond, dai, defaultIsTransferable + ); uint256 afterNextStreamId = openEnded.nextStreamId(); @@ -98,8 +104,9 @@ contract CreateMultiple_Integration_Test is Integration_Test { lastTimeUpdate: uint40(block.timestamp), isCanceled: false, isStream: true, - recipient: users.recipient, - sender: users.sender + isTransferable: IS_TRANFERABLE, + sender: users.sender, + remainingAmount: 0 }); OpenEnded.Stream memory actualStream = openEnded.getStream(streamIds[0]); @@ -107,5 +114,12 @@ contract CreateMultiple_Integration_Test is Integration_Test { actualStream = openEnded.getStream(streamIds[1]); assertEq(actualStream, expectedStream); + + address actualNFTOwner = openEnded.ownerOf({ tokenId: streamIds[0] }); + address expectedNFTOwner = users.recipient; + assertEq(actualNFTOwner, expectedNFTOwner, "NFT owner"); + + actualNFTOwner = openEnded.ownerOf({ tokenId: streamIds[1] }); + assertEq(actualNFTOwner, expectedNFTOwner, "NFT owner"); } } diff --git a/test/integration/create-multiple/createMultiple.tree b/test/integration/create-multiple/createMultiple.tree index 51718611..ad5893e1 100644 --- a/test/integration/create-multiple/createMultiple.tree +++ b/test/integration/create-multiple/createMultiple.tree @@ -9,4 +9,6 @@ createMultiple.t.sol └── when array counts are equal ├── it should create multiple streams ├── it should bump the next stream id multiple times + ├── it should mint multiple NFTs + ├── it should emit multiple {MetadataUpdate} events └── 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 ce6ba201..1ea68d1a 100644 --- a/test/integration/create/create.t.sol +++ b/test/integration/create/create.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.22; +import { IERC721Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ISablierV2OpenEnded } from "src/interfaces/ISablierV2OpenEnded.sol"; @@ -15,19 +16,32 @@ 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)); + bytes memory callData = abi.encodeCall( + ISablierV2OpenEnded.create, (users.sender, users.recipient, RATE_PER_SECOND, dai, IS_TRANFERABLE) + ); expectRevertDueToDelegateCall(callData); } function test_RevertWhen_SenderZeroAddress() external whenNotDelegateCalled { vm.expectRevert(Errors.SablierV2OpenEnded_SenderZeroAddress.selector); - openEnded.create({ sender: address(0), recipient: users.recipient, ratePerSecond: RATE_PER_SECOND, asset: dai }); + openEnded.create({ + sender: address(0), + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + isTransferable: IS_TRANFERABLE + }); } function test_RevertWhen_RecipientZeroAddress() external whenNotDelegateCalled whenSenderNonZeroAddress { - vm.expectRevert(Errors.SablierV2OpenEnded_RecipientZeroAddress.selector); - openEnded.create({ sender: users.sender, recipient: address(0), ratePerSecond: RATE_PER_SECOND, asset: dai }); + vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721InvalidReceiver.selector, address(0))); + openEnded.create({ + sender: users.sender, + recipient: address(0), + ratePerSecond: RATE_PER_SECOND, + asset: dai, + isTransferable: IS_TRANFERABLE + }); } function test_RevertWhen_ratePerSecondZero() @@ -37,7 +51,13 @@ contract Create_Integration_Test is Integration_Test { whenRecipientNonZeroAddress { vm.expectRevert(Errors.SablierV2OpenEnded_RatePerSecondZero.selector); - openEnded.create({ sender: users.sender, recipient: users.recipient, ratePerSecond: 0, asset: dai }); + openEnded.create({ + sender: users.sender, + recipient: users.recipient, + ratePerSecond: 0, + asset: dai, + isTransferable: IS_TRANFERABLE + }); } function test_RevertWhen_AssetNotContract() @@ -45,7 +65,7 @@ contract Create_Integration_Test is Integration_Test { whenNotDelegateCalled whenSenderNonZeroAddress whenRecipientNonZeroAddress - whenratePerSecondNonZero + whenRatePerSecondNonZero { address nonContract = address(8128); vm.expectRevert( @@ -55,7 +75,8 @@ contract Create_Integration_Test is Integration_Test { sender: users.sender, recipient: users.recipient, ratePerSecond: RATE_PER_SECOND, - asset: IERC20(nonContract) + asset: IERC20(nonContract), + isTransferable: IS_TRANFERABLE }); } @@ -64,11 +85,13 @@ contract Create_Integration_Test is Integration_Test { whenNotDelegateCalled whenSenderNonZeroAddress whenRecipientNonZeroAddress - whenratePerSecondNonZero + whenRatePerSecondNonZero whenAssetContract { uint256 expectedStreamId = openEnded.nextStreamId(); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: expectedStreamId }); vm.expectEmit({ emitter: address(openEnded) }); emit CreateOpenEndedStream({ streamId: expectedStreamId, @@ -83,7 +106,8 @@ contract Create_Integration_Test is Integration_Test { sender: users.sender, recipient: users.recipient, ratePerSecond: RATE_PER_SECOND, - asset: dai + asset: dai, + isTransferable: IS_TRANFERABLE }); OpenEnded.Stream memory actualStream = openEnded.getStream(actualStreamId); @@ -95,11 +119,16 @@ contract Create_Integration_Test is Integration_Test { lastTimeUpdate: uint40(block.timestamp), isCanceled: false, isStream: true, - recipient: users.recipient, + isTransferable: IS_TRANFERABLE, + remainingAmount: 0, sender: users.sender }); - assertEq(actualStreamId, expectedStreamId); + assertEq(actualStreamId, expectedStreamId, "stream id"); assertEq(actualStream, expectedStream); + + address actualNFTOwner = openEnded.ownerOf({ tokenId: actualStreamId }); + address expectedNFTOwner = users.recipient; + assertEq(actualNFTOwner, expectedNFTOwner, "NFT owner"); } } diff --git a/test/integration/create/create.tree b/test/integration/create/create.tree index 47b99b95..b1db79bd 100644 --- a/test/integration/create/create.tree +++ b/test/integration/create/create.tree @@ -16,4 +16,6 @@ create.t.sol └── when the asset is a contract ├── it should create the stream ├── it should bump the next stream id + ├── it should mint the NFT + ├── it should emit a {MetadataUpdate} event └── it should emit a {CreateOpenEndedStream} event \ No newline at end of file diff --git a/test/integration/deposit/deposit.t.sol b/test/integration/deposit/deposit.t.sol index f8461346..fc92feb8 100644 --- a/test/integration/deposit/deposit.t.sol +++ b/test/integration/deposit/deposit.t.sol @@ -50,7 +50,7 @@ contract Deposit_Integration_Test is Integration_Test { function test_Deposit(uint256 streamId, IERC20 asset) internal { vm.expectEmit({ emitter: address(asset) }); - emit Transfer({ + emit IERC20.Transfer({ from: users.sender, to: address(openEnded), value: normalizeTransferAmount(streamId, DEPOSIT_AMOUNT) @@ -64,6 +64,9 @@ contract Deposit_Integration_Test is Integration_Test { depositAmount: DEPOSIT_AMOUNT }); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: streamId }); + expectCallToTransferFrom({ asset: asset, from: users.sender, diff --git a/test/integration/deposit/deposit.tree b/test/integration/deposit/deposit.tree index 54914fe8..9b70a187 100644 --- a/test/integration/deposit/deposit.tree +++ b/test/integration/deposit/deposit.tree @@ -17,8 +17,10 @@ deposit.t.sol ├── given the asset does not have 18 decimals │ ├── it should update the stream balance │ ├── it should perform the ERC-20 transfer - │ └── it should emit a {Transfer} and {DepositOpenEndedStream} event + │ ├── it should emit a {Transfer} and {DepositOpenEndedStream} event + │ └── it should emit a {MetadataUpdate} event └── given the asset has 18 decimals ├── it should update the stream balance ├── it should perform the ERC-20 transfer - └── it should emit a {Transfer} and {DepositOpenEndedStream} event \ No newline at end of file + ├── it should emit a {Transfer} and {DepositOpenEndedStream} event + └── it should emit a {MetadataUpdate} event \ No newline at end of file diff --git a/test/integration/refund-from-stream/refundFromStream.t.sol b/test/integration/refund-from-stream/refundFromStream.t.sol index f802696d..fed9f4bd 100644 --- a/test/integration/refund-from-stream/refundFromStream.t.sol +++ b/test/integration/refund-from-stream/refundFromStream.t.sol @@ -122,7 +122,7 @@ contract RefundFromStream_Integration_Test is Integration_Test { function test_RefundFromStream(uint256 streamId, IERC20 asset) internal { vm.expectEmit({ emitter: address(asset) }); - emit Transfer({ + emit IERC20.Transfer({ from: address(openEnded), to: users.sender, value: normalizeTransferAmount(streamId, REFUND_AMOUNT) diff --git a/test/integration/restart-stream/restartStream.t.sol b/test/integration/restart-stream/restartStream.t.sol index 7c29e173..37fa9d67 100644 --- a/test/integration/restart-stream/restartStream.t.sol +++ b/test/integration/restart-stream/restartStream.t.sol @@ -74,8 +74,18 @@ contract RestartStream_Integration_Test is Integration_Test { givenNotNull givenCanceled whenCallerAuthorized - whenratePerSecondNonZero + whenRatePerSecondNonZero { + vm.expectEmit({ emitter: address(openEnded) }); + emit RestartOpenEndedStream({ + streamId: defaultStreamId, + sender: users.sender, + asset: dai, + ratePerSecond: RATE_PER_SECOND + }); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: defaultStreamId }); + openEnded.restartStream({ streamId: defaultStreamId, ratePerSecond: RATE_PER_SECOND }); bool isCanceled = openEnded.isCanceled(defaultStreamId); diff --git a/test/integration/restart-stream/restartStream.tree b/test/integration/restart-stream/restartStream.tree index a85907cf..04986e20 100644 --- a/test/integration/restart-stream/restartStream.tree +++ b/test/integration/restart-stream/restartStream.tree @@ -23,4 +23,5 @@ restartStream.t.sol ├── it should restart the stream ├── it should update the rate per second ├── it should update the time - └── it should emit a {RestartOpenEndedStream} event + ├── it should emit a {RestartOpenEndedStream} event + └── it should emit a {MetadataUpdate} event diff --git a/test/integration/transfer-from/transferFrom.t.sol b/test/integration/transfer-from/transferFrom.t.sol new file mode 100644 index 00000000..39a12caf --- /dev/null +++ b/test/integration/transfer-from/transferFrom.t.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { Errors } from "src/libraries/Errors.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract TransferFrom_Integration_Concrete_Test is Integration_Test { + function setUp() public virtual override { + Integration_Test.setUp(); + resetPrank({ msgSender: users.recipient }); + } + + function test_RevertGiven_StreamNotTransferable() external { + uint256 notTransferableStreamId = openEnded.create({ + sender: users.sender, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + isTransferable: false + }); + vm.expectRevert( + abi.encodeWithSelector(Errors.SablierV2OpenEndedState_NotTransferable.selector, notTransferableStreamId) + ); + openEnded.transferFrom({ from: users.recipient, to: users.eve, tokenId: notTransferableStreamId }); + } + + modifier givenStreamTransferable() { + _; + } + + function test_TransferFrom() external givenStreamTransferable { + // Create a stream. + uint256 streamId = createDefaultStream(); + + // Expect the relevant events to be emitted. + vm.expectEmit({ emitter: address(openEnded) }); + emit Transfer({ from: users.recipient, to: users.sender, tokenId: streamId }); + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: streamId }); + + // Transfer the NFT. + openEnded.transferFrom({ from: users.recipient, to: users.sender, tokenId: streamId }); + + // Assert that Alice is the new stream recipient (and NFT owner). + address actualRecipient = openEnded.getRecipient(streamId); + address expectedRecipient = users.sender; + assertEq(actualRecipient, expectedRecipient, "recipient"); + } +} diff --git a/test/integration/transfer-from/transferFrom.tree b/test/integration/transfer-from/transferFrom.tree new file mode 100644 index 00000000..bc08e8ce --- /dev/null +++ b/test/integration/transfer-from/transferFrom.tree @@ -0,0 +1,7 @@ +transferFrom.t.sol +├── given the stream is not transferable +│ └── it should revert +└── given the stream is transferable + ├── it should transfer the NFT + ├── it should emit a {Transfer} event + └── it should emit a {MetadataUpdate} event \ No newline at end of file diff --git a/test/integration/withdraw-multiple/withdrawAtMultiple.t.sol b/test/integration/withdraw-at-multiple/withdrawAtMultiple.t.sol similarity index 65% rename from test/integration/withdraw-multiple/withdrawAtMultiple.t.sol rename to test/integration/withdraw-at-multiple/withdrawAtMultiple.t.sol index efdb672f..03d77ba4 100644 --- a/test/integration/withdraw-multiple/withdrawAtMultiple.t.sol +++ b/test/integration/withdraw-at-multiple/withdrawAtMultiple.t.sol @@ -1,7 +1,6 @@ // 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"; @@ -17,11 +16,6 @@ contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { vm.warp({ newTimestamp: WARP_ONE_MONTH }); } - function test_RevertWhen_DelegateCall() external { - bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.withdrawAtMultiple, (defaultStreamIds, times)); - expectRevertDueToDelegateCall(callData); - } - function test_RevertWhen_ArrayCountsNotEqual() external whenNotDelegateCalled { uint256[] memory streamIds = new uint256[](0); uint40[] memory _times = new uint40[](1); @@ -31,20 +25,12 @@ contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { openEnded.withdrawAtMultiple(streamIds, _times); } - modifier whenArrayCountsAreEqual() { - _; - } - function test_WithdrawMultiple_ArrayCountsZero() external whenNotDelegateCalled whenArrayCountsAreEqual { uint256[] memory streamIds = new uint256[](0); uint40[] memory _times = new uint40[](0); openEnded.withdrawAtMultiple(streamIds, _times); } - modifier whenArrayCountsNotZero() { - _; - } - function test_RevertGiven_OnlyNull() external whenNotDelegateCalled @@ -68,67 +54,38 @@ contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { openEnded.withdrawAtMultiple({ streamIds: defaultStreamIds, times: times }); } - function test_RevertGiven_OnlyCanceled() + function test_RevertWhen_OnlyLastTimeLessThanWithdrawalTimes() external whenNotDelegateCalled whenArrayCountsAreEqual whenArrayCountsNotZero givenNotNull - { - openEnded.cancel(defaultStreamIds[1]); - expectRevertCanceled(); - openEnded.withdrawAtMultiple({ streamIds: defaultStreamIds, times: times }); - } - - function test_RevertGiven_SomeCanceled() - external - whenNotDelegateCalled - whenArrayCountsAreEqual - whenArrayCountsNotZero - givenNotNull - { - expectRevertCanceled(); - openEnded.withdrawAtMultiple({ streamIds: defaultStreamIds, times: times }); - } - - function test_RevertWhen_OnlyWithdrawalTimesNotGreaterThanLastTimeUpdate() - external - whenNotDelegateCalled - whenArrayCountsAreEqual - whenArrayCountsNotZero - givenNotNull - givenNotCanceled { uint40 lastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[0]); - times[0] = lastTimeUpdate; + times[0] = lastTimeUpdate - 1; vm.expectRevert( abi.encodeWithSelector( - Errors.SablierV2OpenEnded_WithdrawalTimeNotGreaterThanLastUpdate.selector, - lastTimeUpdate, - lastTimeUpdate + Errors.SablierV2OpenEnded_LastUpdateNotLessThanWithdrawalTime.selector, lastTimeUpdate, times[0] ) ); openEnded.withdrawAtMultiple({ streamIds: defaultStreamIds, times: times }); } - function test_RevertWhen_SomeWithdrawalTimesNotGreaterThanLastTimeUpdate() + function test_RevertWhen_SomeLastTimeLessThanWithdrawalTimes() external whenNotDelegateCalled whenArrayCountsAreEqual whenArrayCountsNotZero givenNotNull - givenNotCanceled { uint40 lastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[0]); - times[0] = lastTimeUpdate; - times[1] = lastTimeUpdate; + times[0] = lastTimeUpdate - 1; + times[1] = lastTimeUpdate - 1; vm.expectRevert( abi.encodeWithSelector( - Errors.SablierV2OpenEnded_WithdrawalTimeNotGreaterThanLastUpdate.selector, - lastTimeUpdate, - lastTimeUpdate + Errors.SablierV2OpenEnded_LastUpdateNotLessThanWithdrawalTime.selector, lastTimeUpdate, times[0] ) ); openEnded.withdrawAtMultiple({ streamIds: defaultStreamIds, times: times }); @@ -140,8 +97,7 @@ contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { whenArrayCountsAreEqual whenArrayCountsNotZero givenNotNull - givenNotCanceled - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime { uint40 futureTime = uint40(block.timestamp + 1); times[0] = futureTime; @@ -161,8 +117,7 @@ contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { whenArrayCountsAreEqual whenArrayCountsNotZero givenNotNull - givenNotCanceled - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime { defaultDeposit(); @@ -177,48 +132,13 @@ contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { openEnded.withdrawAtMultiple({ 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.withdrawAtMultiple({ 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.withdrawAtMultiple({ streamIds: defaultStreamIds, times: times }); - } - - function test_withdrawAtMultiple() + function test_WithdrawAtMultiple() external whenNotDelegateCalled whenArrayCountsAreEqual whenArrayCountsNotZero givenNotNull - givenNotCanceled - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime whenWithdrawalTimeNotInTheFuture givenBalanceNotZero { @@ -237,14 +157,14 @@ contract WithdrawMultiple_Integration_Concrete_Test is Integration_Test { streamId: defaultStreamIds[0], to: users.recipient, asset: dai, - withdrawAmount: WITHDRAW_AMOUNT + withdrawnAmount: WITHDRAW_AMOUNT }); vm.expectEmit({ emitter: address(openEnded) }); emit WithdrawFromOpenEndedStream({ streamId: defaultStreamIds[1], to: users.recipient, asset: dai, - withdrawAmount: WITHDRAW_AMOUNT + withdrawnAmount: WITHDRAW_AMOUNT }); openEnded.withdrawAtMultiple({ streamIds: defaultStreamIds, times: times }); diff --git a/test/integration/withdraw-at-multiple/withdrawAtMultiple.tree b/test/integration/withdraw-at-multiple/withdrawAtMultiple.tree new file mode 100644 index 00000000..c5029fda --- /dev/null +++ b/test/integration/withdraw-at-multiple/withdrawAtMultiple.tree @@ -0,0 +1,25 @@ +withdrawAtMultiple.t.sol +├── 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 + ├── when all last time update are less than withdrawal times + │ └── it should revert + ├── when some last time update are less than withdrawal times + │ └── it should revert + └── when all last time update are not less than withdrawal times + ├── 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 + ├── 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/withdrawAt.t.sol b/test/integration/withdraw-at/withdrawAt.t.sol similarity index 67% rename from test/integration/withdraw/withdrawAt.t.sol rename to test/integration/withdraw-at/withdrawAt.t.sol index 2ac3a550..44fc5602 100644 --- a/test/integration/withdraw/withdrawAt.t.sol +++ b/test/integration/withdraw-at/withdrawAt.t.sol @@ -28,21 +28,10 @@ contract Withdraw_Integration_Test is Integration_Test { openEnded.withdrawAt({ streamId: nullStreamId, to: users.recipient, time: WITHDRAW_TIME }); } - function test_RevertGiven_Canceled() external whenNotDelegateCalled givenNotNull { - expectRevertCanceled(); - openEnded.withdrawAt({ streamId: defaultStreamId, to: users.recipient, time: WITHDRAW_TIME }); - } - - function test_RevertWhen_ToZeroAddress() external whenNotDelegateCalled givenNotNull givenNotCanceled { - vm.expectRevert(Errors.SablierV2OpenEnded_WithdrawToZeroAddress.selector); - openEnded.withdrawAt({ streamId: defaultStreamId, to: address(0), time: WITHDRAW_TIME }); - } - function test_RevertWhen_CallerUnknown() external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressNotRecipient { @@ -65,7 +54,6 @@ contract Withdraw_Integration_Test is Integration_Test { external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressNotRecipient { @@ -83,32 +71,32 @@ contract Withdraw_Integration_Test is Integration_Test { openEnded.withdrawAt({ streamId: defaultStreamId, to: users.sender, time: WITHDRAW_TIME }); } - function test_RevertWhen_WithdrawalTimeNotGreaterThanLastUpdate() + function test_RevertWhen_LastTimeNotLessThanWithdrawalTime() external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressIsRecipient { + uint40 lastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamId); + vm.expectRevert( abi.encodeWithSelector( - Errors.SablierV2OpenEnded_WithdrawalTimeNotGreaterThanLastUpdate.selector, - 0, - openEnded.getLastTimeUpdate(defaultStreamId) + Errors.SablierV2OpenEnded_LastUpdateNotLessThanWithdrawalTime.selector, + lastTimeUpdate, + lastTimeUpdate - 1 ) ); - openEnded.withdrawAt({ streamId: defaultStreamId, to: users.recipient, time: 0 }); + openEnded.withdrawAt({ streamId: defaultStreamId, to: users.recipient, time: lastTimeUpdate - 1 }); } function test_RevertWhen_WithdrawalTimeInTheFuture() external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressIsRecipient - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime { uint40 futureTime = uint40(block.timestamp + 1); @@ -124,30 +112,65 @@ contract Withdraw_Integration_Test is Integration_Test { external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressIsRecipient - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime whenWithdrawalTimeNotInTheFuture + givenRemainingAmountZero { vm.warp({ newTimestamp: WARP_ONE_MONTH - ONE_MONTH }); uint256 streamId = createDefaultStream(); vm.warp({ newTimestamp: WARP_ONE_MONTH }); - vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_WithdrawBalanceZero.selector, streamId)); + vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_WithdrawNoFundsAvailable.selector, streamId)); openEnded.withdrawAt({ streamId: streamId, to: users.recipient, time: WITHDRAW_TIME }); } - function test_Withdraw_CallerSender() + function test_WithdrawAt_BalanceZero() external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressIsRecipient - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime + whenWithdrawalTimeNotInTheFuture + givenRemainingAmountNotZero + { + vm.warp({ newTimestamp: WARP_ONE_MONTH - ONE_MONTH }); + uint256 streamId = createDefaultStream(); + vm.warp({ newTimestamp: WITHDRAW_TIME }); + + openEnded.deposit(streamId, WITHDRAW_AMOUNT); + openEnded.adjustRatePerSecond(streamId, RATE_PER_SECOND - 1); + + uint128 actualRemainingAmount = openEnded.getRemainingAmount(streamId); + uint128 expectedRemainingAmount = WITHDRAW_AMOUNT; + assertEq(actualRemainingAmount, expectedRemainingAmount, "remaining amount"); + + uint128 actualStreamBalance = openEnded.getBalance(streamId); + uint128 expectedStreamBalance = 0; + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + + openEnded.withdrawAt({ streamId: streamId, to: users.recipient, time: WITHDRAW_TIME }); + + actualRemainingAmount = openEnded.getRemainingAmount(streamId); + expectedRemainingAmount = 0; + assertEq(actualRemainingAmount, expectedRemainingAmount, "remaining amount"); + + actualStreamBalance = openEnded.getBalance(streamId); + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + } + + function test_WithdrawAt_CallerSender() + external + whenNotDelegateCalled + givenNotNull + whenToNonZeroAddress + whenWithdrawalAddressIsRecipient + whenLastTimeNotLessThanWithdrawalTime whenWithdrawalTimeNotInTheFuture givenBalanceNotZero + givenRemainingAmountZero { openEnded.withdrawAt({ streamId: defaultStreamId, to: users.recipient, time: WITHDRAW_TIME }); @@ -160,16 +183,16 @@ contract Withdraw_Integration_Test is Integration_Test { assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); } - function test_Withdraw_CallerUnknown() + function test_WithdrawAt_CallerUnknown() external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressIsRecipient - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime whenWithdrawalTimeNotInTheFuture givenBalanceNotZero + givenRemainingAmountZero { address unknownCaller = address(0xCAFE); resetPrank({ msgSender: unknownCaller }); @@ -185,14 +208,51 @@ contract Withdraw_Integration_Test is Integration_Test { assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); } - function test_Withdraw_AssetNot18Decimals() + function test_WithdrawAt_StreamCanceled() + external + whenNotDelegateCalled + givenNotNull + whenToNonZeroAddress + whenWithdrawalAddressIsRecipient + whenLastTimeNotLessThanWithdrawalTime + whenWithdrawalTimeNotInTheFuture + givenBalanceNotZero + givenRemainingAmountNotZero + whenCallerRecipient + { + openEnded.cancel(defaultStreamId); + + uint128 actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + uint128 expectedRemainingAmount = ONE_MONTH_STREAMED_AMOUNT; + assertEq(actualRemainingAmount, expectedRemainingAmount, "remaining amount"); + + vm.expectEmit({ emitter: address(dai) }); + emit IERC20.Transfer({ from: address(openEnded), to: users.recipient, value: ONE_MONTH_STREAMED_AMOUNT }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit WithdrawFromOpenEndedStream({ + streamId: defaultStreamId, + to: users.recipient, + asset: dai, + withdrawnAmount: ONE_MONTH_STREAMED_AMOUNT + }); + + expectCallToTransfer({ asset: dai, to: users.recipient, amount: ONE_MONTH_STREAMED_AMOUNT }); + + openEnded.withdrawAt({ streamId: defaultStreamId, to: users.recipient, time: WITHDRAW_TIME }); + + actualRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + expectedRemainingAmount = 0; + assertEq(actualRemainingAmount, expectedRemainingAmount, "remaining amount"); + } + + function test_WithdrawAt_AssetNot18Decimals() external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressIsRecipient - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime whenWithdrawalTimeNotInTheFuture givenBalanceNotZero whenCallerRecipient @@ -210,10 +270,9 @@ contract Withdraw_Integration_Test is Integration_Test { external whenNotDelegateCalled givenNotNull - givenNotCanceled whenToNonZeroAddress whenWithdrawalAddressIsRecipient - whenWithdrawalTimeGreaterThanLastUpdate + whenLastTimeNotLessThanWithdrawalTime whenWithdrawalTimeNotInTheFuture givenBalanceNotZero whenCallerRecipient @@ -229,7 +288,7 @@ contract Withdraw_Integration_Test is Integration_Test { assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); vm.expectEmit({ emitter: address(asset) }); - emit Transfer({ + emit IERC20.Transfer({ from: address(openEnded), to: users.recipient, value: normalizeTransferAmount(streamId, WITHDRAW_AMOUNT) @@ -240,7 +299,7 @@ contract Withdraw_Integration_Test is Integration_Test { streamId: streamId, to: users.recipient, asset: asset, - withdrawAmount: WITHDRAW_AMOUNT + withdrawnAmount: WITHDRAW_AMOUNT }); expectCallToTransfer({ diff --git a/test/integration/withdraw-at/withdrawAt.tree b/test/integration/withdraw-at/withdrawAt.tree new file mode 100644 index 00000000..a3700930 --- /dev/null +++ b/test/integration/withdraw-at/withdrawAt.tree @@ -0,0 +1,53 @@ +withdrawAt.t.sol +├── when delegate called +│ └── it should revert +└── when not delegate called + ├── given the id references a null stream + │ └── it should revert + └── given the id does not reference a null stream + ├── when the provided address is zero + │ └── it should revert + └── when the provided address is not zero + ├── when the withdrawal address is not the stream recipient + │ ├── when the caller is the sender + │ │ └── it should revert + │ └── when the caller is unknown + │ └── it should revert + └── when the withdrawal address is the stream recipient + ├── when the last time update is not less than withdrawal time + │ └── it should revert + └── when the last time update is less than the withdrawal time + ├── when the withdrawal time is in the future + │ └── it should revert + └── when the withdrawal time is not in the future + ├── given the balance is zero + │ ├── given the remaining amount is zero + │ │ └── it should revert + │ └── given the remaining amount is not zero + │ └── it make the withdrawal + └── given the balance is not zero + ├── when the caller is not the recipient + │ ├── when the caller is the sender + │ │ └── it should make the withdrawal + │ └── when the caller is unknown + │ └── it should make the withdrawal + └── when the caller is the recipient + ├── when the stream is canceled + │ └── it should withdraw the remaining amount + └── when the stream is not canceled + ├── given the asset does not have 18 decimals + │ ├── it should make the withdrawal + │ ├── it should update the time + │ ├── it should set the remaining amount to zero + │ ├── it should update the stream balance + │ ├── it should perform the ERC-20 transfer + │ ├── it should emit a {Transfer} and {WithdrawFromOpenEndedStream} event + │ └── it should emit a {MetadataUpdate} event + └── given the asset has 18 decimals + ├── it should make the withdrawal + ├── it should update the time + ├── it should set the remaining amount to zero + ├── it should update the stream balance + ├── it should perform the ERC-20 transfer + ├── it should emit a {Transfer} and {WithdrawFromOpenEndedStream} event + └── it should emit a {MetadataUpdate} event \ No newline at end of file diff --git a/test/integration/withdraw-max-multiple/withdrawMaxMultiple.t.sol b/test/integration/withdraw-max-multiple/withdrawMaxMultiple.t.sol new file mode 100644 index 00000000..eb531f12 --- /dev/null +++ b/test/integration/withdraw-max-multiple/withdrawMaxMultiple.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { Errors } from "src/libraries/Errors.sol"; +import { Integration_Test } from "../Integration.t.sol"; + +contract WithdrawMaxMultiple_Integration_Concrete_Test is Integration_Test { + function setUp() public override { + Integration_Test.setUp(); + + vm.warp({ newTimestamp: WARP_ONE_MONTH }); + } + + function test_WithdrawMaxMultiple_ArrayCountZero() external { + uint256[] memory streamIds = new uint256[](0); + openEnded.withdrawMaxMultiple(streamIds); + } + + function test_RevertGiven_OnlyNull() external whenArrayCountsNotZero { + defaultStreamIds[0] = nullStreamId; + defaultStreamIds[1] = nullStreamId; + vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_Null.selector, nullStreamId)); + openEnded.withdrawMaxMultiple({ streamIds: defaultStreamIds }); + } + + function test_RevertGiven_SomeNull() external whenArrayCountsNotZero { + defaultStreamIds[0] = nullStreamId; + vm.expectRevert(abi.encodeWithSelector(Errors.SablierV2OpenEnded_Null.selector, nullStreamId)); + openEnded.withdrawMaxMultiple({ streamIds: defaultStreamIds }); + } + + function test_WithdrawMaxMultiple() external whenArrayCountsNotZero givenNotNull { + 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(dai) }); + emit IERC20.Transfer({ + from: address(openEnded), + to: users.recipient, + value: normalizeTransferAmount(defaultStreamIds[0], ONE_MONTH_STREAMED_AMOUNT) + }); + vm.expectEmit({ emitter: address(openEnded) }); + emit WithdrawFromOpenEndedStream({ + streamId: defaultStreamIds[0], + to: users.recipient, + asset: dai, + withdrawnAmount: ONE_MONTH_STREAMED_AMOUNT + }); + + vm.expectEmit({ emitter: address(dai) }); + emit IERC20.Transfer({ + from: address(openEnded), + to: users.recipient, + value: normalizeTransferAmount(defaultStreamIds[1], ONE_MONTH_STREAMED_AMOUNT) + }); + vm.expectEmit({ emitter: address(openEnded) }); + emit WithdrawFromOpenEndedStream({ + streamId: defaultStreamIds[1], + to: users.recipient, + asset: dai, + withdrawnAmount: ONE_MONTH_STREAMED_AMOUNT + }); + + openEnded.withdrawMaxMultiple({ streamIds: defaultStreamIds }); + + assertEq(openEnded.getRemainingAmount(defaultStreamIds[0]), 0, "remaining amount"); + assertEq(openEnded.getRemainingAmount(defaultStreamIds[1]), 0, "remaining amount"); + + uint128 actualStreamBalance = openEnded.getBalance(defaultStreamIds[0]); + uint128 expectedStreamBalance = DEPOSIT_AMOUNT - ONE_MONTH_STREAMED_AMOUNT; + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + + actualStreamBalance = openEnded.getBalance(defaultStreamIds[1]); + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + + actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[0]); + expectedLastTimeUpdate = WARP_ONE_MONTH; + assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); + + actualLastTimeUpdate = openEnded.getLastTimeUpdate(defaultStreamIds[1]); + assertEq(actualLastTimeUpdate, expectedLastTimeUpdate, "last time updated"); + } +} diff --git a/test/integration/withdraw-max-multiple/withdrawMaxMultiple.tree b/test/integration/withdraw-max-multiple/withdrawMaxMultiple.tree new file mode 100644 index 00000000..8bdd5f6b --- /dev/null +++ b/test/integration/withdraw-max-multiple/withdrawMaxMultiple.tree @@ -0,0 +1,17 @@ +withdrawMaxMultiple.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 + ├── it should make the max withdrawal + ├── it should set the remaining amount to zero + ├── it should update the stream balance + ├── it should update the time + ├── it should perform the ERC-20 transfer + └── it should emit a {Transfer} and {WithdrawFromOpenEndedStream} event + + diff --git a/test/integration/withdraw-max/withdrawMax.t.sol b/test/integration/withdraw-max/withdrawMax.t.sol new file mode 100644 index 00000000..73078551 --- /dev/null +++ b/test/integration/withdraw-max/withdrawMax.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract WithdrawMax_Integration_Concrete_Test is Integration_Test { + function setUp() public override { + Integration_Test.setUp(); + + defaultDeposit(); + + vm.warp({ newTimestamp: WARP_ONE_MONTH }); + } + + function test_WithdrawMax_Canceled() external { + openEnded.cancel(defaultStreamId); + + uint128 beforeStreamBalance = openEnded.getBalance(defaultStreamId); + uint128 beforeRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + + vm.expectEmit({ emitter: address(dai) }); + emit IERC20.Transfer({ + from: address(openEnded), + to: users.recipient, + value: normalizeTransferAmount(defaultStreamId, beforeRemainingAmount) + }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit WithdrawFromOpenEndedStream({ + streamId: defaultStreamId, + to: users.recipient, + asset: dai, + withdrawnAmount: beforeRemainingAmount + }); + + openEnded.withdrawMax(defaultStreamId, users.recipient); + + uint128 afterStreamBalance = openEnded.getBalance(defaultStreamId); + uint128 afterRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + + assertEq(beforeStreamBalance, afterStreamBalance, "stream balance should not change"); + assertEq(afterRemainingAmount, 0, "remaining amount should be 0"); + assertEq(openEnded.getLastTimeUpdate(defaultStreamId), WARP_ONE_MONTH, "last time update not updated"); + } + + function test_WithdrawMax() external givenNotCanceled { + uint128 beforeStreamBalance = openEnded.getBalance(defaultStreamId); + uint128 beforeRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + + vm.expectEmit({ emitter: address(dai) }); + emit IERC20.Transfer({ + from: address(openEnded), + to: users.recipient, + value: normalizeTransferAmount(defaultStreamId, ONE_MONTH_STREAMED_AMOUNT) + }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit WithdrawFromOpenEndedStream({ + streamId: defaultStreamId, + to: users.recipient, + asset: dai, + withdrawnAmount: beforeRemainingAmount + ONE_MONTH_STREAMED_AMOUNT + }); + + openEnded.withdrawMax(defaultStreamId, users.recipient); + + uint128 afterStreamBalance = openEnded.getBalance(defaultStreamId); + uint128 afterRemainingAmount = openEnded.getRemainingAmount(defaultStreamId); + + assertEq( + beforeStreamBalance - ONE_MONTH_STREAMED_AMOUNT, afterStreamBalance, "stream balance not updated correctly" + ); + assertEq(afterRemainingAmount, 0, "remaining amount should be 0"); + assertEq(openEnded.getLastTimeUpdate(defaultStreamId), WARP_ONE_MONTH, "last time update not updated"); + } +} diff --git a/test/integration/withdraw-max/withdrawMax.tree b/test/integration/withdraw-max/withdrawMax.tree new file mode 100644 index 00000000..722ba55e --- /dev/null +++ b/test/integration/withdraw-max/withdrawMax.tree @@ -0,0 +1,10 @@ +withdrawMax.t.sol +├── given the stream is canceled +│ └── it should withdraw the remaining amount +└── given the end time is not canceled + ├── it should make the max withdrawal + ├── it should set the remaining amount to zero + ├── it should update the stream balance + ├── it should update the time + ├── it should perform the ERC-20 transfer + └── it should emit a {Transfer} and {WithdrawFromOpenEndedStream} event \ No newline at end of file diff --git a/test/integration/withdraw-multiple/withdrawAtMultiple.tree b/test/integration/withdraw-multiple/withdrawAtMultiple.tree deleted file mode 100644 index 24ebca96..00000000 --- a/test/integration/withdraw-multiple/withdrawAtMultiple.tree +++ /dev/null @@ -1,38 +0,0 @@ -withdrawAtMultiple.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/withdrawAt.tree b/test/integration/withdraw/withdrawAt.tree deleted file mode 100644 index 99403414..00000000 --- a/test/integration/withdraw/withdrawAt.tree +++ /dev/null @@ -1,46 +0,0 @@ -withdrawAt.t.sol -├── when delegate called -│ └── it should revert -└── when not delegate called - ├── given the id references a null stream - │ └── it should revert - └── given the id does not reference a null stream - ├── given the id references a canceled stream - │ └── it should revert - └── given the id does not reference a canceled stream - ├── when the provided address is zero - │ └── it should revert - └── when the provided address is not zero - ├── when the withdrawal address is not the stream recipient - │ ├── when the caller is the sender - │ │ └── it should revert - │ └── when the caller is unknown - │ └── it should revert - └── when the withdrawal address is the stream recipient - ├── when the withdrawal time is not strictly greater than last time update - │ └── it should revert - └── when the withdrawal time is strictly greater than last time update - ├── when the withdrawal time is in the future - │ └── it should revert - └── when the withdrawal time is not in the future - ├── given the balance is zero - │ └── it should revert - └── given the balance is not zero - ├── when the caller is not the recipient - │ ├── when the caller is the sender - │ │ └── it should make the withdrawal - │ └── when the caller is unknown - │ └── it should make the withdrawal - └── when the caller is the recipient - ├── given the asset does not have 18 decimals - │ ├── it should make the withdrawal - │ ├── it should update the time - │ ├── it should update the stream balance - │ ├── it should perform the ERC-20 transfer - │ └── it should emit a {Transfer} and {WithdrawFromOpenEndedStream} event - └── given the asset has 18 decimals - ├── it should make the withdrawal - ├── it should update the time - ├── it should update the stream balance - ├── it should perform the ERC-20 transfer - └── it should emit a {Transfer} and {WithdrawFromOpenEndedStream} event \ No newline at end of file diff --git a/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol b/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol index d9d678f2..4eb6b271 100644 --- a/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol +++ b/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol @@ -6,6 +6,9 @@ import { Integration_Test } from "../Integration.t.sol"; contract WithdrawableAmountOf_Integration_Test is Integration_Test { function setUp() public override { Integration_Test.setUp(); + + openEnded.deposit(defaultStreamId, ONE_MONTH_STREAMED_AMOUNT); + vm.warp({ newTimestamp: WARP_ONE_MONTH }); } function test_RevertGiven_Null() external { @@ -13,30 +16,92 @@ contract WithdrawableAmountOf_Integration_Test is Integration_Test { openEnded.withdrawableAmountOf(nullStreamId); } - function test_RevertGiven_Canceled() external givenNotNull { - expectRevertCanceled(); - openEnded.withdrawableAmountOf(defaultStreamId); + function test_WithdrawableAmountOf_Canceled() external givenNotNull { + openEnded.cancel(defaultStreamId); + + uint128 actualWithdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); + uint128 expectedWithdrawableAmount = openEnded.getRemainingAmount(defaultStreamId); + assertEq(actualWithdrawableAmount, expectedWithdrawableAmount, "withdrawable amount"); } - function test_WithdrawableAmountOf_BalanceZero() external view givenNotNull givenNotCanceled { - uint128 withdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); - assertEq(withdrawableAmount, 0, "withdrawable amount"); + function test_WithdrawableAmountOf_RemainingAmountZero() + external + givenNotNull + givenNotCanceled + givenBalanceZero + givenRemainingAmountZero + { + vm.warp({ newTimestamp: WARP_ONE_MONTH - ONE_MONTH }); + uint256 streamId = createDefaultStream(); + vm.warp({ newTimestamp: WARP_ONE_MONTH }); + + assertEq(openEnded.getBalance(streamId), 0, "stream balance"); + assertEq(openEnded.getRemainingAmount(streamId), 0, "remaining amount"); + assertEq(openEnded.withdrawableAmountOf(streamId), 0, "withdrawable amount"); + } + + function test_WithdrawableAmountOf_RemainingAmountNotZero() + external + givenNotNull + givenNotCanceled + givenBalanceZero + givenRemainingAmountNotZero + { + // Adjust the rate per second so that the remaining amount is greater than zero. + openEnded.adjustRatePerSecond(defaultStreamId, RATE_PER_SECOND + 1); + + assertEq(openEnded.getBalance(defaultStreamId), 0, "stream balance"); + + uint128 actualWithdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); + uint128 expectedWithdrawableAmount = openEnded.getRemainingAmount(defaultStreamId); + assertEq(actualWithdrawableAmount, expectedWithdrawableAmount, "withdrawable amount"); } - function test_WithdrawableAmountOf_BalanceLessThanOrEqualStreamedAmount() external givenNotNull givenNotCanceled { + function test_WithdrawableAmountOf_BalanceLessThanOrEqualStreamedAmount() + external + givenNotNull + givenNotCanceled + givenBalanceNotZero + givenRemainingAmountNotZero + { + // Adjust the rate per second so that the remaining amount is greater than zero. + openEnded.adjustRatePerSecond(defaultStreamId, RATE_PER_SECOND + 1); + uint128 depositAmount = 1e18; + + // Deposit more funds. openEnded.deposit(defaultStreamId, depositAmount); - vm.warp({ newTimestamp: WARP_ONE_MONTH }); - uint128 withdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); - assertEq(withdrawableAmount, depositAmount, "withdrawable amount"); + // Warp one more month into the future. + vm.warp({ newTimestamp: block.timestamp + ONE_MONTH }); + + uint128 actualWithdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); + uint128 expectedWithdrawableAmount = openEnded.getRemainingAmount(defaultStreamId) + depositAmount; + assertEq(actualWithdrawableAmount, expectedWithdrawableAmount, "withdrawable amount"); } - function test_WithdrawableAmountOf() external givenNotNull givenNotCanceled { + function test_WithdrawableAmountOf() + external + givenNotNull + givenNotCanceled + givenBalanceNotZero + givenRemainingAmountNotZero + { + uint128 newRatePerSecond = RATE_PER_SECOND + 1; // 0.001e18 + 1 + + // Adjust the rate per second so that the remaining amount is greater than zero. + openEnded.adjustRatePerSecond(defaultStreamId, newRatePerSecond); + + // Deposit more funds. defaultDeposit(); - vm.warp({ newTimestamp: WARP_ONE_MONTH }); - uint128 withdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); - assertEq(withdrawableAmount, ONE_MONTH_STREAMED_AMOUNT, "withdrawable amount"); + // Warp one more month into the future. + vm.warp({ newTimestamp: block.timestamp + ONE_MONTH }); + + uint128 oneMonthStreamedAmount = ONE_MONTH * newRatePerSecond; + + uint128 actualWithdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); + uint128 expectedWithdrawableAmount = openEnded.getRemainingAmount(defaultStreamId) + oneMonthStreamedAmount; + assertEq(actualWithdrawableAmount, expectedWithdrawableAmount, "withdrawable amount"); } } diff --git a/test/integration/withdrawable-amount-of/withdrawableAmountOf.tree b/test/integration/withdrawable-amount-of/withdrawableAmountOf.tree index 2e3162dd..f373dc7d 100644 --- a/test/integration/withdrawable-amount-of/withdrawableAmountOf.tree +++ b/test/integration/withdrawable-amount-of/withdrawableAmountOf.tree @@ -3,12 +3,15 @@ withdrawableAmountOf.t.sol │ └── it should revert └── given the id does not reference a null stream ├── given the id references a canceled stream - │ └── it should revert + │ └── it should return the remaining amount └── given the id does not reference a canceled stream ├── given the stream balance is zero - │ └── it should return zero + │ ├── given the remaining amount is zero + │ │ └── it should return zero + │ └── given the remaining amount is not zero + │ └── it should return the remaining amount └── given the stream balance is not zero ├── given the stream balance is less than or equal to the streamed amount - │ └── it should return the balance + │ └── it should return the balance plus the remaining amount └── given the stream balance is greater than the streamed amount └── it should return the correct withdrawable amount \ No newline at end of file diff --git a/test/invariant/OpenEnded.t.sol b/test/invariant/OpenEnded.t.sol index 1d8f2afe..a63a483e 100644 --- a/test/invariant/OpenEnded.t.sol +++ b/test/invariant/OpenEnded.t.sol @@ -71,24 +71,27 @@ contract OpenEnded_Invariant_Test is Invariant_Test { } } - function invariant_ContractBalanceGeStreamBalancesSumNormalized() external useCurrentTimestamp { + function invariant_ContractBalanceGeStreamBalancesAndRemainingAmountsSum() external useCurrentTimestamp { uint256 contractBalance = dai.balanceOf(address(openEnded)); uint256 lastStreamId = openEndedStore.lastStreamId(); uint256 streamBalancesSumNormalized; + uint256 remainingAmountsSumNormalized; for (uint256 i = 0; i < lastStreamId; ++i) { uint256 streamId = openEndedStore.streamIds(i); streamBalancesSumNormalized += uint256(normalizeBalance(streamId)); + remainingAmountsSumNormalized += + uint256(normalizeTransferAmount(streamId, openEndedStore.remainingAmountsSum(streamId))); } assertGe( contractBalance, - streamBalancesSumNormalized, - unicode"Invariant violation: contract balances < Σ stream balances normalized" + streamBalancesSumNormalized + remainingAmountsSumNormalized, + unicode"Invariant violation: contract balanceOf < Σ stream balances + remaining amounts normalized" ); } - function invariant_DepositedAmountsSumGeExtractedAmountsSum() external useCurrentTimestamp { + function invariant_DepositedAmountsSumGeExtractedAmountsSumPlusRemainingAmount() external useCurrentTimestamp { uint256 streamDepositedAmountsSum = openEndedStore.streamDepositedAmountsSum(); uint256 streamExtractedAmountsSum = openEndedStore.streamExtractedAmountsSum(); @@ -107,29 +110,19 @@ contract OpenEnded_Invariant_Test is Invariant_Test { } } - function invariant_StreamBalanceEqWithdrawableAmountPlusRefundableAmount() external useCurrentTimestamp { + function invariant_StreamBalanceEqWithdrawableAmountPlusRefundableAmountMinusRemainingAmount() + external + useCurrentTimestamp + { uint256 lastStreamId = openEndedStore.lastStreamId(); for (uint256 i = 0; i < lastStreamId; ++i) { uint256 streamId = openEndedStore.streamIds(i); if (!openEnded.isCanceled(streamId)) { assertEq( openEnded.getBalance(streamId), - openEnded.withdrawableAmountOf(streamId) + openEnded.refundableAmountOf(streamId), - "Invariant violation: stream balance != withdrawable amount + refundable amount" - ); - } - } - } - - function invariant_StreamBalanceGeWithdrawableAmount() external useCurrentTimestamp { - uint256 lastStreamId = openEndedStore.lastStreamId(); - for (uint256 i = 0; i < lastStreamId; ++i) { - uint256 streamId = openEndedStore.streamIds(i); - if (!openEnded.isCanceled(streamId)) { - assertGe( - openEnded.getBalance(streamId), - openEnded.withdrawableAmountOf(streamId), - "Invariant violation: stream balance < withdrawable amount" + openEnded.withdrawableAmountOf(streamId) + openEnded.refundableAmountOf(streamId) + - openEnded.getRemainingAmount(streamId), + "Invariant violation: stream balance != withdrawable amount + refundable amount - remaining amount" ); } } @@ -149,21 +142,19 @@ contract OpenEnded_Invariant_Test is Invariant_Test { } } - function invariant_StreamedAmountGeWithdrawableAmount() external useCurrentTimestamp { + function invariatn_StreamCanceled_BalanceZero() external useCurrentTimestamp { uint256 lastStreamId = openEndedStore.lastStreamId(); for (uint256 i = 0; i < lastStreamId; ++i) { uint256 streamId = openEndedStore.streamIds(i); - if (!openEnded.isCanceled(streamId)) { - assertGe( - openEnded.streamedAmountOf(streamId), - openEnded.withdrawableAmountOf(streamId), - "Invariant violation: streamed amount < withdrawable amount" + if (openEnded.isCanceled(streamId)) { + assertEq( + openEnded.getBalance(streamId), 0, "Invariant violation: canceled stream with a non-zero balance" ); } } } - function invariant_StreamCanceled() external useCurrentTimestamp { + function invariant_StreamCanceled_RatePerSecondZero() external useCurrentTimestamp { uint256 lastStreamId = openEndedStore.lastStreamId(); for (uint256 i = 0; i < lastStreamId; ++i) { uint256 streamId = openEndedStore.streamIds(i); @@ -173,10 +164,44 @@ contract OpenEnded_Invariant_Test is Invariant_Test { 0, "Invariant violation: canceled stream with a non-zero rate per second" ); + } + } + } + + function invariant_StreamedCanceled_WithdrawableAmountEqRemainingAmount() external useCurrentTimestamp { + uint256 lastStreamId = openEndedStore.lastStreamId(); + for (uint256 i = 0; i < lastStreamId; ++i) { + uint256 streamId = openEndedStore.streamIds(i); + if (openEnded.isCanceled(streamId)) { assertEq( - openEnded.getBalance(streamId), 0, "Invariant violation: canceled stream with a non-zero balance" + openEnded.withdrawableAmountOf(streamId), + openEnded.getRemainingAmount(streamId), + "Invariant violation: canceled stream withdrawable amount != remaining amount" ); } } } + + /// @dev The invariant is: withdrawable amount = min(balance, streamed amount) + remaining amount + /// This includes both canceled and non-canceled streams. + function invariant_WithdrawableAmount() external useCurrentTimestamp { + uint256 lastStreamId = openEndedStore.lastStreamId(); + for (uint256 i = 0; i < lastStreamId; ++i) { + uint256 streamId = openEndedStore.streamIds(i); + uint128 balance = openEnded.getBalance(streamId); + uint128 streamedAmount = 0; + + if (!openEnded.isCanceled(streamId)) { + streamedAmount = openEnded.streamedAmountOf(streamId); + } + + uint128 balanceOrStreamedAmount = balance > streamedAmount ? streamedAmount : balance; + + assertEq( + openEnded.withdrawableAmountOf(streamId), + balanceOrStreamedAmount + openEnded.getRemainingAmount(streamId), + "Invariant violation: withdrawable amount != min(balance, streamed amount) + remaining amount" + ); + } + } } diff --git a/test/invariant/handlers/OpenEndedCreateHandler.sol b/test/invariant/handlers/OpenEndedCreateHandler.sol index 14526bca..bfa5f8f3 100644 --- a/test/invariant/handlers/OpenEndedCreateHandler.sol +++ b/test/invariant/handlers/OpenEndedCreateHandler.sol @@ -40,17 +40,21 @@ contract OpenEndedCreateHandler is BaseHandler { HANDLER FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ - function create( - uint256 timeJumpSeed, - address sender, - address recipient, - uint128 ratePerSecond - ) + /// @dev Struct to prevent stack too deep error. + struct CreateParams { + uint256 timeJumpSeed; + address sender; + address recipient; + uint128 ratePerSecond; + bool isTransferable; + } + + function create(CreateParams memory params) public instrument("createAndDeposit") - adjustTimestamp(timeJumpSeed) - checkUsers(sender, recipient) - useNewSender(sender) + adjustTimestamp(params.timeJumpSeed) + checkUsers(params.sender, params.recipient) + useNewSender(params.sender) { // We don't want to create more than a certain number of streams. if (openEndedStore.lastStreamId() >= MAX_STREAM_COUNT) { @@ -58,28 +62,26 @@ contract OpenEndedCreateHandler is BaseHandler { } // Bound the stream parameters. - ratePerSecond = uint128(_bound(ratePerSecond, 0.0001e18, 1e18)); + params.ratePerSecond = uint128(_bound(params.ratePerSecond, 0.0001e18, 1e18)); // Create the stream. asset = asset; - uint256 streamId = openEnded.create(sender, recipient, ratePerSecond, asset); + uint256 streamId = + openEnded.create(params.sender, params.recipient, params.ratePerSecond, asset, params.isTransferable); // Store the stream id. - openEndedStore.pushStreamId(streamId, sender, recipient); + openEndedStore.pushStreamId(streamId, params.sender, params.recipient); } function createAndDeposit( - uint256 timeJumpSeed, - address sender, - address recipient, - uint128 ratePerSecond, + CreateParams memory params, uint128 depositAmount ) public instrument("createAndDeposit") - adjustTimestamp(timeJumpSeed) - checkUsers(sender, recipient) - useNewSender(sender) + adjustTimestamp(params.timeJumpSeed) + checkUsers(params.sender, params.recipient) + useNewSender(params.sender) { // We don't want to create more than a certain number of streams. if (openEndedStore.lastStreamId() >= MAX_STREAM_COUNT) { @@ -87,21 +89,22 @@ contract OpenEndedCreateHandler is BaseHandler { } // Bound the stream parameters. - ratePerSecond = uint128(_bound(ratePerSecond, 0.0001e18, 1e18)); + params.ratePerSecond = uint128(_bound(params.ratePerSecond, 0.0001e18, 1e18)); depositAmount = uint128(_bound(depositAmount, 100e18, 1_000_000_000e18)); // Mint enough assets to the Sender. - deal({ token: address(asset), to: sender, give: asset.balanceOf(sender) + depositAmount }); + deal({ token: address(asset), to: params.sender, give: asset.balanceOf(params.sender) + depositAmount }); // Approve {SablierV2OpenEnded} to spend the assets. asset.approve({ spender: address(openEnded), value: depositAmount }); // Create the stream. - asset = asset; - uint256 streamId = openEnded.createAndDeposit(sender, recipient, ratePerSecond, asset, depositAmount); + uint256 streamId = openEnded.createAndDeposit( + params.sender, params.recipient, params.ratePerSecond, asset, params.isTransferable, depositAmount + ); // Store the stream id. - openEndedStore.pushStreamId(streamId, sender, recipient); + openEndedStore.pushStreamId(streamId, params.sender, params.recipient); // Store the deposited amount. openEndedStore.updateStreamDepositedAmountsSum(depositAmount); diff --git a/test/invariant/handlers/OpenEndedHandler.sol b/test/invariant/handlers/OpenEndedHandler.sol index 22357323..e32b93e6 100644 --- a/test/invariant/handlers/OpenEndedHandler.sol +++ b/test/invariant/handlers/OpenEndedHandler.sol @@ -75,6 +75,42 @@ contract OpenEndedHandler is BaseHandler { SABLIER-V2-OPENENDED //////////////////////////////////////////////////////////////////////////*/ + function adjustRatePerSecond( + uint256 timeJumpSeed, + uint256 streamIndexSeed, + uint128 newRatePerSecond + ) + external + instrument("adjustRatePerSecond") + adjustTimestamp(timeJumpSeed) + useFuzzedStream(streamIndexSeed) + useFuzzedStreamSender + { + // Only non canceled streams can have their rate per second adjusted. + if (openEnded.isCanceled(currentStreamId)) { + return; + } + + // Bound the rate per second. + newRatePerSecond = uint128(_bound(newRatePerSecond, 0.0001e18, 1e18)); + + // The rate per second must be different from the current rate per second. + if (newRatePerSecond == openEnded.getRatePerSecond(currentStreamId)) { + newRatePerSecond += 1; + } + + uint128 balance = openEnded.getBalance(currentStreamId); + uint128 streamedAmount = openEnded.streamedAmountOf(currentStreamId); + + uint128 remainingAmount = balance > streamedAmount ? streamedAmount : balance; + + // Adjust the rate per second. + openEnded.adjustRatePerSecond(currentStreamId, newRatePerSecond); + + // Store the remaining amount. + openEndedStore.sumRemainingAmount(currentStreamId, remainingAmount); + } + function cancel( uint256 timeJumpSeed, uint256 streamIndexSeed @@ -90,14 +126,20 @@ contract OpenEndedHandler is BaseHandler { return; } + uint128 balance = openEnded.getBalance(currentStreamId); uint128 senderAmount = openEnded.refundableAmountOf(currentStreamId); - uint128 recipientAmount = openEnded.withdrawableAmountOf(currentStreamId); + uint128 streamedAmount = openEnded.streamedAmountOf(currentStreamId); + + uint128 remainingAmount = balance > streamedAmount ? streamedAmount : balance; // Cancel the stream. openEnded.cancel(currentStreamId); // Store the extracted amount. - openEndedStore.updateStreamExtractedAmountsSum(senderAmount + recipientAmount); + openEndedStore.updateStreamExtractedAmountsSum(senderAmount); + + // Store the remaining amount. + openEndedStore.sumRemainingAmount(currentStreamId, remainingAmount); } function deposit( @@ -228,27 +270,23 @@ contract OpenEndedHandler is BaseHandler { uint40 time ) external - instrument("withdraw") + instrument("withdrawAt") adjustTimestamp(timeJumpSeed) useFuzzedStream(streamIndexSeed) useFuzzedStreamRecipient { - // Canceled streams cannot be withdrawn from. - if (openEnded.isCanceled(currentStreamId)) { - return; - } - // The protocol doesn't allow the withdrawal address to be the zero address. if (to == address(0)) { return; } - if (openEnded.getBalance(currentStreamId) == 0) { + // If the balance and the remaining amount are zero, there is nothing to withdraw. + if (openEnded.getBalance(currentStreamId) == 0 && openEnded.getRemainingAmount(currentStreamId) == 0) { return; } // Bound the time so that it is between last time update and current time. - time = uint40(_bound(time, openEnded.getLastTimeUpdate(currentStreamId) + 1, block.timestamp)); + time = uint40(_bound(time, openEnded.getLastTimeUpdate(currentStreamId), block.timestamp)); // There is an edge case when the sender is the same as the recipient. In this scenario, the withdrawal // address must be set to the recipient. @@ -257,6 +295,7 @@ contract OpenEndedHandler is BaseHandler { to = currentRecipient; } + uint128 remainingAmount = openEnded.getRemainingAmount(currentStreamId); uint128 withdrawAmount = openEnded.withdrawableAmountOf(currentStreamId, time); // Withdraw from the stream. @@ -264,5 +303,8 @@ contract OpenEndedHandler is BaseHandler { // Store the extracted amount. openEndedStore.updateStreamExtractedAmountsSum(withdrawAmount); + + // Remove the remaining amount. + openEndedStore.subtractRemainingAmount(currentStreamId, remainingAmount); } } diff --git a/test/invariant/stores/OpenEndedStore.sol b/test/invariant/stores/OpenEndedStore.sol index e44fa82e..c711b030 100644 --- a/test/invariant/stores/OpenEndedStore.sol +++ b/test/invariant/stores/OpenEndedStore.sol @@ -10,6 +10,7 @@ contract OpenEndedStore { uint256 public lastStreamId; mapping(uint256 streamId => address recipient) public recipients; mapping(uint256 streamId => address sender) public senders; + mapping(uint256 streamId => uint128 remainingAmount) public remainingAmountsSum; uint256[] public streamIds; uint256 public streamDepositedAmountsSum; uint256 public streamExtractedAmountsSum; @@ -28,6 +29,14 @@ contract OpenEndedStore { lastStreamId = streamId; } + function sumRemainingAmount(uint256 streamId, uint128 amount) external { + remainingAmountsSum[streamId] += amount; + } + + function subtractRemainingAmount(uint256 streamId, uint128 amount) external { + remainingAmountsSum[streamId] -= amount; + } + function updateStreamDepositedAmountsSum(uint256 amount) external { streamDepositedAmountsSum += amount; } diff --git a/test/utils/Assertions.sol b/test/utils/Assertions.sol index 01cc5770..e0568f33 100644 --- a/test/utils/Assertions.sol +++ b/test/utils/Assertions.sol @@ -30,7 +30,6 @@ abstract contract Assertions is StdAssertions { assertEq(a.lastTimeUpdate, b.lastTimeUpdate, "lastTimeUpdate"); assertEq(a.isCanceled, b.isCanceled, "isCanceled"); assertEq(a.isStream, b.isStream, "isStream"); - assertEq(a.recipient, b.recipient, "recipient"); assertEq(a.sender, b.sender, "sender"); } } diff --git a/test/utils/Events.sol b/test/utils/Events.sol index 2d70e7a0..7f6afc52 100644 --- a/test/utils/Events.sol +++ b/test/utils/Events.sol @@ -4,14 +4,24 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; abstract contract Events { - event Transfer(address indexed from, address indexed to, uint256 value); + /*////////////////////////////////////////////////////////////////////////// + ERC-721 + //////////////////////////////////////////////////////////////////////////*/ + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + /*////////////////////////////////////////////////////////////////////////// + ERC-4906 + //////////////////////////////////////////////////////////////////////////*/ + + event MetadataUpdate(uint256 _tokenId); + + /*////////////////////////////////////////////////////////////////////////// + OPEN-ENDED + //////////////////////////////////////////////////////////////////////////*/ event AdjustOpenEndedStream( - uint256 indexed streamId, - IERC20 indexed asset, - uint128 recipientAmount, - uint128 oldRatePerSecond, - uint128 newRatePerSecond + uint256 indexed streamId, uint128 recipientAmount, uint128 oldRatePerSecond, uint128 newRatePerSecond ); event CancelOpenEndedStream( @@ -45,6 +55,6 @@ abstract contract Events { ); event WithdrawFromOpenEndedStream( - uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 withdrawAmount + uint256 indexed streamId, address indexed to, IERC20 indexed asset, uint128 withdrawnAmount ); } diff --git a/test/utils/Modifiers.sol b/test/utils/Modifiers.sol index ba8eac9e..4dd0cd2e 100644 --- a/test/utils/Modifiers.sol +++ b/test/utils/Modifiers.sol @@ -6,7 +6,27 @@ abstract contract Modifiers { COMMON //////////////////////////////////////////////////////////////////////////*/ - modifier whenratePerSecondNonZero() { + modifier givenBalanceNotZero() { + _; + } + + modifier givenBalanceZero() { + _; + } + + modifier givenNotCanceled() { + _; + } + + modifier givenNotNull() { + _; + } + + modifier givenRemainingAmountZero() { + _; + } + + modifier givenRemainingAmountNotZero() { _; } @@ -22,19 +42,27 @@ abstract contract Modifiers { _; } - modifier givenNotCanceled() { + modifier whenRatePerSecondNonZero() { _; } - modifier givenNotNull() { + /*////////////////////////////////////////////////////////////////////////// + ADJUST-AMOUNT-PER-SECOND + //////////////////////////////////////////////////////////////////////////*/ + + modifier whenRatePerSecondNotDifferent() { _; } /*////////////////////////////////////////////////////////////////////////// - ADJUST-AMOUNT-PER-SECOND + CANCEL //////////////////////////////////////////////////////////////////////////*/ - modifier whenratePerSecondNotDifferent() { + modifier whenRefundableAmountNotZero() { + _; + } + + modifier whenWithdrawableAmountNotZero() { _; } @@ -103,14 +131,14 @@ abstract contract Modifiers { } /*////////////////////////////////////////////////////////////////////////// - WITHDRAW + WITHDRAW-AT //////////////////////////////////////////////////////////////////////////*/ - modifier givenBalanceNotZero() { + modifier whenCallerRecipient() { _; } - modifier whenCallerRecipient() { + modifier whenLastTimeNotLessThanWithdrawalTime() { _; } @@ -130,7 +158,15 @@ abstract contract Modifiers { _; } - modifier whenWithdrawalTimeGreaterThanLastUpdate() { + /*////////////////////////////////////////////////////////////////////////// + WITHDRAW-AT-MULTIPLE + //////////////////////////////////////////////////////////////////////////*/ + + modifier whenArrayCountsAreEqual() { + _; + } + + modifier whenArrayCountsNotZero() { _; } }