From cafd89348893653e733cbf452a44b1298c6675ab Mon Sep 17 00:00:00 2001 From: smol-ninja Date: Fri, 24 May 2024 13:45:49 +0100 Subject: [PATCH] `depositViaBroker` and `createAndDepositViaBroker` functions (#100) * feat: add depositViaBroker and createAndDepositViaBroker test: depositViaBroker test: createAnddepositViaBroker refactor: address pr review test: move broker function from Base to Integration test: remove duplicated modifiers test: remove unneeded tree branches test: fix CreateAndDepositViaBroker_Integration_Test * style: remove unused imports * test: order normalize functions alphabetically test: rename whenRecipientNonZeroAddress to whenRecipientIsNotZeroAddress * test: say "Asset" instead of "Token" test: order modifiers alphabetically test: add delegate call test for withdrawMax test: merge pause modifiers into a single one --------- Co-authored-by: andreivladbrg --- README.md | 2 +- src/SablierV2OpenEnded.sol | 94 ++++++++++- src/abstracts/SablierV2OpenEndedState.sol | 4 + src/interfaces/ISablierV2OpenEnded.sol | 63 ++++++- src/interfaces/ISablierV2OpenEndedState.sol | 6 + src/libraries/Errors.sol | 11 +- src/types/DataTypes.sol | 10 +- test/Base.t.sol | 39 +++-- test/integration/Integration.t.sol | 8 + .../adjustRatePerSecond.t.sol | 4 +- test/integration/constructor.t.sol | 8 + .../createAndDepositViaBroker.t.sol | 124 ++++++++++++++ .../createAndDepositViaBroker.tree | 10 ++ test/integration/create/create.t.sol | 18 +- .../deposit-via-broker/depositViaBroker.t.sol | 154 ++++++++++++++++++ .../deposit-via-broker/depositViaBroker.tree | 27 +++ test/integration/deposit/deposit.t.sol | 4 +- test/integration/pause/pause.t.sol | 3 +- .../refund-from-stream/refundFromStream.t.sol | 8 +- .../restart-stream/restartStream.t.sol | 2 +- test/integration/withdraw-at/withdrawAt.t.sol | 4 +- .../withdraw-max/withdrawMax.t.sol | 8 +- .../integration/withdraw-max/withdrawMax.tree | 23 +-- test/invariant/OpenEnded.t.sol | 2 +- test/mocks/ERC20Mock.sol | 10 +- test/utils/Assertions.sol | 2 + test/utils/Constants.sol | 7 +- test/utils/Modifiers.sol | 44 +++-- 28 files changed, 618 insertions(+), 81 deletions(-) create mode 100644 test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.t.sol create mode 100644 test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.tree create mode 100644 test/integration/deposit-via-broker/depositViaBroker.t.sol create mode 100644 test/integration/deposit-via-broker/depositViaBroker.tree diff --git a/README.md b/README.md index 256b36fb..010e4643 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ internal balance and a rate-per-second in the Stream entity: - Top up, which are public (you can ask a friend to deposit money for you instead) - No deposits are required at the time of stream creation; thus, creation and deposit are distinct operations. - There are no deposit limits. -- Streams can be created for an indefinite period, they will be collecting debt until the sender deposits or cancels the +- Streams can be created for an indefinite period, they will be collecting debt until the sender deposits or pauses the stream. - Ability to pause and restart streams. - The sender can refund from the stream balance at any time. diff --git a/src/SablierV2OpenEnded.sol b/src/SablierV2OpenEnded.sol index f8b5c0d5..7f592000 100644 --- a/src/SablierV2OpenEnded.sol +++ b/src/SablierV2OpenEnded.sol @@ -6,12 +6,13 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { ud } from "@prb/math/src/UD60x18.sol"; import { NoDelegateCall } from "./abstracts/NoDelegateCall.sol"; import { SablierV2OpenEndedState } from "./abstracts/SablierV2OpenEndedState.sol"; import { ISablierV2OpenEnded } from "./interfaces/ISablierV2OpenEnded.sol"; import { Errors } from "./libraries/Errors.sol"; -import { OpenEnded } from "./types/DataTypes.sol"; +import { Broker, OpenEnded } from "./types/DataTypes.sol"; /// @title SablierV2OpenEnded /// @notice See the documentation in {ISablierV2OpenEnded}. @@ -206,15 +207,38 @@ contract SablierV2OpenEnded is ) external override + noDelegateCall returns (uint256 streamId) { // Checks, Effects and Interactions: create the stream. - streamId = create(sender, recipient, ratePerSecond, asset, isTransferable); + streamId = _create(sender, recipient, ratePerSecond, asset, isTransferable); // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); } + /// @inheritdoc ISablierV2OpenEnded + function createAndDepositViaBroker( + address sender, + address recipient, + uint128 ratePerSecond, + IERC20 asset, + bool isTransferable, + uint128 totalAmount, + Broker calldata broker + ) + external + override + noDelegateCall + returns (uint256 streamId) + { + // Checks, Effects and Interactions: create the stream. + streamId = _create(sender, recipient, ratePerSecond, asset, isTransferable); + + // Checks, Effects and Interactions: deposit into stream through {depositViaBroker}. + _depositViaBroker(streamId, totalAmount, broker); + } + /// @inheritdoc ISablierV2OpenEnded function deposit( uint256 streamId, @@ -230,6 +254,21 @@ contract SablierV2OpenEnded is _deposit(streamId, amount); } + function depositViaBroker( + uint256 streamId, + uint128 totalAmount, + Broker calldata broker + ) + public + override + noDelegateCall + notNull(streamId) + updateMetadata(streamId) + { + // Checks, Effects and Interactions: deposit on stream through broker. + _depositViaBroker(streamId, totalAmount, broker); + } + /// @inheritdoc ISablierV2OpenEnded function pause(uint256 streamId) public @@ -261,9 +300,20 @@ contract SablierV2OpenEnded is } /// @inheritdoc ISablierV2OpenEnded - function restartStreamAndDeposit(uint256 streamId, uint128 ratePerSecond, uint128 amount) external override { + function restartStreamAndDeposit( + uint256 streamId, + uint128 ratePerSecond, + uint128 amount + ) + external + override + noDelegateCall + notNull(streamId) + onlySender(streamId) + updateMetadata(streamId) + { // Checks, Effects and Interactions: restart the stream. - restartStream(streamId, ratePerSecond); + _restartStream(streamId, ratePerSecond); // Checks, Effects and Interactions: deposit on stream. _deposit(streamId, amount); @@ -301,9 +351,18 @@ contract SablierV2OpenEnded is } /// @inheritdoc ISablierV2OpenEnded - function withdrawMax(uint256 streamId, address to) external override { + function withdrawMax( + uint256 streamId, + address to + ) + external + override + noDelegateCall + notNull(streamId) + updateMetadata(streamId) + { // Checks, Effects and Interactions: make the withdrawal. - withdrawAt(streamId, to, uint40(block.timestamp)); + _withdrawAt(streamId, to, uint40(block.timestamp)); } /*////////////////////////////////////////////////////////////////////////// @@ -538,6 +597,29 @@ contract SablierV2OpenEnded is emit ISablierV2OpenEnded.DepositOpenEndedStream(streamId, msg.sender, asset, amount); } + /// @dev See the documentation for the user-facing functions that call this internal function. + function _depositViaBroker(uint256 streamId, uint128 totalAmount, Broker memory broker) internal { + // Check: the broker's fee is not greater than `MAX_BROKER_FEE`. + if (broker.fee.gt(MAX_BROKER_FEE)) { + revert Errors.SablierV2OpenEnded_BrokerFeeTooHigh(streamId, broker.fee, MAX_BROKER_FEE); + } + + // Check: the broker recipient is not the zero address. + if (broker.account == address(0)) { + revert Errors.SablierV2OpenEnded_BrokerAddressZero(); + } + + // Calculate the broker's amount. + uint128 brokerAmountIn18Decimals = uint128(ud(totalAmount).mul(broker.fee).intoUint256()); + uint128 brokerAmount = _calculateTransferAmount(streamId, brokerAmountIn18Decimals); + + // Checks, Effects and Interactions: deposit on stream. + _deposit({ streamId: streamId, amount: totalAmount - brokerAmountIn18Decimals }); + + // Interaction: transfer the broker's amount. + _streams[streamId].asset.safeTransferFrom(msg.sender, broker.account, brokerAmount); + } + /// @dev Helper function to calculate the transfer amount and to perform the ERC-20 transfer. function _extractFromStream(uint256 streamId, address to, uint128 amount) internal { // Calculate the transfer amount. diff --git a/src/abstracts/SablierV2OpenEndedState.sol b/src/abstracts/SablierV2OpenEndedState.sol index 70742f58..e405710e 100644 --- a/src/abstracts/SablierV2OpenEndedState.sol +++ b/src/abstracts/SablierV2OpenEndedState.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.22; import { IERC4906 } from "@openzeppelin/contracts/interfaces/IERC4906.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; import { ISablierV2OpenEndedState } from "../interfaces/ISablierV2OpenEndedState.sol"; import { OpenEnded } from "../types/DataTypes.sol"; @@ -20,6 +21,9 @@ abstract contract SablierV2OpenEndedState is STATE VARIABLES //////////////////////////////////////////////////////////////////////////*/ + /// @inheritdoc ISablierV2OpenEndedState + UD60x18 public constant override MAX_BROKER_FEE = UD60x18.wrap(0.1e18); + /// @inheritdoc ISablierV2OpenEndedState uint256 public override nextStreamId; diff --git a/src/interfaces/ISablierV2OpenEnded.sol b/src/interfaces/ISablierV2OpenEnded.sol index 89c615b9..131b4be9 100644 --- a/src/interfaces/ISablierV2OpenEnded.sol +++ b/src/interfaces/ISablierV2OpenEnded.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ISablierV2OpenEndedState } from "./ISablierV2OpenEndedState.sol"; +import { Broker } from "../types/DataTypes.sol"; /// @title ISablierV2OpenEnded /// @notice Creates and manages Open Ended streams with linear streaming functions. @@ -44,7 +45,7 @@ interface ISablierV2OpenEnded is /// @param streamId The ID of the open-ended stream. /// @param funder The address which funded the stream. /// @param asset The contract address of the ERC-20 asset used for streaming. - /// @param depositAmount The amount of assets deposited, denoted in 18 decimals. + /// @param depositAmount The amount of assets deposited into the stream, denoted in 18 decimals. event DepositOpenEndedStream( uint256 indexed streamId, address indexed funder, IERC20 indexed asset, uint128 depositAmount ); @@ -170,7 +171,7 @@ interface ISablierV2OpenEnded is /// @param newRatePerSecond The new rate per second of the open-ended stream, denoted in 18 decimals. function adjustRatePerSecond(uint256 streamId, uint128 newRatePerSecond) external; - /// @notice Creates a new open-ended stream with the `block.timestamp` as the time reference and with zero balance. + /// @notice Creates a new open-ended stream with `block.timestamp` as `lastTimeUpdate` and set stream balance to 0. /// The stream is wrapped in an ERC-721 NFT. /// /// @dev Emits a {CreateOpenEndedStream} event. @@ -185,6 +186,7 @@ interface ISablierV2OpenEnded is /// @param recipient The address receiving the assets. /// @param sender The address streaming the assets, with the ability to adjust and pause the stream. It doesn't /// have to be the same as `msg.sender`. + /// @param sender The address streaming the assets. It doesn't have to be the same as `msg.sender`. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. /// @param asset The contract address of the ERC-20 asset used for streaming. /// @param isTransferable Boolean indicating if the stream NFT is transferable. @@ -199,18 +201,16 @@ interface ISablierV2OpenEnded is external returns (uint256 streamId); - /// @notice Creates a new open-ended stream with the `block.timestamp` as the time reference - /// and with `amount` balance. The stream is wrapped in an ERC-721 NFT. + /// @notice Creates a new open-ended stream with `block.timestamp` as `lastTimeUpdate` and set the stream balance to + /// `amount`. The stream is wrapped in an ERC-721 NFT. /// /// @dev Emits a {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. /// /// Requirements: - /// - `amount` must be greater than zero. - /// - Refer to the requirements in {create}. + /// - Refer to the requirements in {create} and {deposit}. /// /// @param recipient The address receiving the assets. - /// @param sender The address streaming the assets, with the ability to adjust and pause the stream. It doesn't - /// have to be the same as `msg.sender`. + /// @param sender The address streaming the assets. It doesn't have to be the same as `msg.sender`. /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. /// @param asset The contract address of the ERC-20 asset used for streaming. /// @param isTransferable Boolean indicating if the stream NFT is transferable. @@ -227,6 +227,36 @@ interface ISablierV2OpenEnded is external returns (uint256 streamId); + /// @notice Creates a new open-ended stream with `block.timestamp` as `lastTimeUpdate` and set the stream balance to + /// an amount calculated from the `totalAmount` after broker fee amount deduction. The stream is wrapped in an + /// ERC-721 NFT. + /// + /// @dev Emits a {CreateOpenEndedStream}, {Transfer} and {DepositOpenEndedStream} events. + /// + /// Requirements: + /// - Refer to the requirements in {create} and {depositViaBroker}. + /// + /// @param recipient The address receiving the assets. + /// @param sender The address streaming the assets. It doesn't have to be the same as `msg.sender`. + /// @param ratePerSecond The amount of assets that is increasing by every second, denoted in 18 decimals. + /// @param asset The contract address of the ERC-20 asset used for streaming. + /// @param isTransferable Boolean indicating if the stream NFT is transferable. + /// @param totalAmount The total amount, including the stream deposit and broker fee amount, both denoted in 18 + /// decimals. + /// @param broker The broker's address and fee. + /// @return streamId The ID of the newly created stream. + function createAndDepositViaBroker( + address recipient, + address sender, + uint128 ratePerSecond, + IERC20 asset, + bool isTransferable, + uint128 totalAmount, + Broker calldata broker + ) + external + returns (uint256 streamId); + /// @notice Deposits assets in a stream. /// /// @dev Emits a {Transfer} and {DepositOpenEndedStream} event. @@ -240,6 +270,23 @@ interface ISablierV2OpenEnded is /// @param amount The amount deposited in the stream, denoted in 18 decimals. function deposit(uint256 streamId, uint128 amount) external; + /// @notice Deposits assets in a stream. + /// + /// @dev Emits a {Transfer} and {DepositOpenEndedStream} event. + /// + /// Requirements: + /// - Must not be delegate called. + /// - `streamId` must not reference a null stream. + /// - `totalAmount` must be greater than zero. Otherwise it will revert inside {deposit}. + /// - `broker.account` must not be 0 address. + /// - `broker.fee` must not be greater than `MAX_BROKER_FEE`. It can be zero. + /// + /// @param streamId The ID of the stream to deposit on. + /// @param totalAmount The total amount, including the stream deposit and broker fee amount, both denoted in 18 + /// decimals. + /// @param broker The broker's address and fee. + function depositViaBroker(uint256 streamId, uint128 totalAmount, Broker calldata broker) external; + /// @notice Pauses the stream and refunds available assets to the sender. /// /// @dev Emits a {Transfer} and {PauseOpenEndedStream} event. diff --git a/src/interfaces/ISablierV2OpenEndedState.sol b/src/interfaces/ISablierV2OpenEndedState.sol index 3657f431..90b8543a 100644 --- a/src/interfaces/ISablierV2OpenEndedState.sol +++ b/src/interfaces/ISablierV2OpenEndedState.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; import { OpenEnded } from "../types/DataTypes.sol"; @@ -72,6 +73,11 @@ interface ISablierV2OpenEndedState is /// @param streamId The stream ID for the query. function isStream(uint256 streamId) external view returns (bool result); + /// @notice Retrieves the maximum broker fee that can be charged by the broker, denoted as a fixed-point number + /// where 1e18 is 100%. + /// @dev This value is hard coded as a constant. + function MAX_BROKER_FEE() external view returns (UD60x18 fee); + /// @notice Counter for stream ids. /// @return The next stream id. function nextStreamId() external view returns (uint256); diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index c668340b..09efec64 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; /// @title Errors /// @notice Library with custom erros used across the OpenEnded contract. @@ -17,7 +18,13 @@ library Errors { SABLIER-V2-OpenEnded //////////////////////////////////////////////////////////////////////////*/ - /// @notice Thrown when trying to create a OpenEnded stream with a zero deposit amount. + /// @notice Thrown when trying to create a stream with a broker fee more than the allowed. + error SablierV2OpenEnded_BrokerFeeTooHigh(uint256 streamId, UD60x18 fee, UD60x18 maxFee); + + /// @notice Thrown when trying to create a stream with a broker recipient address as zero. + error SablierV2OpenEnded_BrokerAddressZero(); + + /// @notice Thrown when trying to create a stream with a zero deposit amount. error SablierV2OpenEnded_DepositAmountZero(); /// @notice Thrown when trying to create a stream with an asset with no decimals. @@ -48,7 +55,7 @@ library Errors { /// @notice Thrown when trying to refund zero assets from a stream. error SablierV2OpenEnded_RefundAmountZero(); - /// @notice Thrown when trying to create a OpenEnded stream with the sender as the zero address. + /// @notice Thrown when trying to create a stream with the sender as the zero address. error SablierV2OpenEnded_SenderZeroAddress(); /// @notice Thrown when trying to perform an action with a paused stream. diff --git a/src/types/DataTypes.sol b/src/types/DataTypes.sol index b68a2a77..f7583e25 100644 --- a/src/types/DataTypes.sol +++ b/src/types/DataTypes.sol @@ -2,8 +2,16 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; -// TODO: add Broker +/// @notice Struct encapsulating the broker parameters passed to the `depositViaBroker` and `createAndDepositViaBroker` +/// functions. +/// @param account The address receiving the broker's fee. +/// @param fee The broker's percentage fee from the amount passed, denoted as a fixed-point number where 1e18 is 100%. +struct Broker { + address account; + UD60x18 fee; +} library OpenEnded { /// @notice OpenEnded stream. diff --git a/test/Base.t.sol b/test/Base.t.sol index 50c9ae6d..b78d439e 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -29,8 +29,10 @@ abstract contract Base_Test is Assertions, Constants, Events, Modifiers, Test, U TEST CONTRACTS //////////////////////////////////////////////////////////////////////////*/ - ERC20Mock internal dai = new ERC20Mock("Dai stablecoin", "DAI"); + ERC20Mock internal assetWithoutDecimals = new ERC20Mock("Asset without decimals", "AWD", 0); + ERC20Mock internal dai = new ERC20Mock("Dai stablecoin", "DAI", 18); SablierV2OpenEnded internal openEnded; + ERC20Mock internal usdc = new ERC20Mock("USD Coin", "USDC", 6); ERC20MissingReturn internal usdt = new ERC20MissingReturn("USDT stablecoin", "USDT", 6); /*////////////////////////////////////////////////////////////////////////// @@ -44,6 +46,7 @@ abstract contract Base_Test is Assertions, Constants, Events, Modifiers, Test, U openEnded = deployOptimizedOpenEnded(); } + users.broker = createUser("broker"); users.eve = createUser("eve"); users.recipient = createUser("recipient"); users.sender = createUser("sender"); @@ -64,9 +67,11 @@ abstract contract Base_Test is Assertions, Constants, Events, Modifiers, Test, U address payable user = payable(makeAddr(name)); vm.deal({ account: user, newBalance: 100 ether }); deal({ token: address(dai), to: user, give: 1_000_000e18 }); + deal({ token: address(usdc), to: user, give: 1_000_000e6 }); deal({ token: address(usdt), to: user, give: 1_000_000e18 }); resetPrank(user); dai.approve({ spender: address(openEnded), value: type(uint256).max }); + usdc.approve({ spender: address(openEnded), value: type(uint256).max }); usdt.approve({ spender: address(openEnded), value: type(uint256).max }); return user; } @@ -82,35 +87,39 @@ abstract contract Base_Test is Assertions, Constants, Events, Modifiers, Test, U vm.label(address(usdt), "USDT"); } - function normalizeBalance(uint256 streamId) internal view returns (uint256) { - return normalizeTransferAmount(streamId, openEnded.getBalance(streamId)); - } - - function normalizeTransferAmount( - uint256 streamId, - uint128 amount + /// @dev Normalizes `amount` to `decimals`. + function normalizeAmountToDecimal( + uint128 amount, + uint8 decimals ) internal - view + pure returns (uint128 normalizedAmount) { - // Retrieve the asset's decimals from storage. - uint8 assetDecimals = openEnded.getAssetDecimals(streamId); - // Return the original amount if it's already in the standard 18-decimal format. - if (assetDecimals == 18) { + if (decimals == 18) { return amount; } - bool isGreaterThan18 = assetDecimals > 18; + bool isGreaterThan18 = decimals > 18; - uint8 normalizationFactor = isGreaterThan18 ? assetDecimals - 18 : 18 - assetDecimals; + uint8 normalizationFactor = isGreaterThan18 ? decimals - 18 : 18 - decimals; normalizedAmount = isGreaterThan18 ? (amount * (10 ** normalizationFactor)).toUint128() : (amount / (10 ** normalizationFactor)).toUint128(); } + /// @dev Normalizes `amount` to the decimal of `streamId` asset. + function normalizeAmountWithStreamId(uint256 streamId, uint128 amount) internal view returns (uint256) { + return normalizeAmountToDecimal(amount, openEnded.getAssetDecimals(streamId)); + } + + /// @dev Normalizes stream balance to the decimal of `streamId` asset. + function normalizeStreamBalance(uint256 streamId) internal view returns (uint256) { + return normalizeAmountToDecimal(openEnded.getBalance(streamId), openEnded.getAssetDecimals(streamId)); + } + /*////////////////////////////////////////////////////////////////////////// CALL EXPECTS //////////////////////////////////////////////////////////////////////////*/ diff --git a/test/integration/Integration.t.sol b/test/integration/Integration.t.sol index 305ac5db..d27f6723 100644 --- a/test/integration/Integration.t.sol +++ b/test/integration/Integration.t.sol @@ -4,16 +4,20 @@ pragma solidity >=0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { Errors } from "src/libraries/Errors.sol"; +import { Broker } from "src/types/DataTypes.sol"; import { Base_Test } from "../Base.t.sol"; abstract contract Integration_Test is Base_Test { + Broker internal defaultBroker; uint256 internal defaultStreamId; uint256 internal nullStreamId = 420; function setUp() public virtual override { Base_Test.setUp(); + defaultBroker = broker(); + defaultStreamId = createDefaultStream(); } @@ -21,6 +25,10 @@ abstract contract Integration_Test is Base_Test { HELPERS //////////////////////////////////////////////////////////////////////////*/ + function broker() public view returns (Broker memory) { + return Broker({ account: users.broker, fee: BROKER_FEE }); + } + function createDefaultStream() internal returns (uint256) { return createDefaultStreamWithAsset(dai); } diff --git a/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol b/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol index 4cf02429..f47b3090 100644 --- a/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol +++ b/test/integration/adjust-rate-per-second/adjustRatePerSecond.t.sol @@ -72,7 +72,7 @@ contract AdjustRatePerSecond_Integration_Test is Integration_Test { givenNotNull givenNotPaused whenCallerIsTheSender - whenRatePerSecondNonZero + whenRatePerSecondIsNotZero { vm.expectRevert( abi.encodeWithSelector(Errors.SablierV2OpenEnded_RatePerSecondNotDifferent.selector, RATE_PER_SECOND) @@ -86,7 +86,7 @@ contract AdjustRatePerSecond_Integration_Test is Integration_Test { givenNotNull givenNotPaused whenCallerIsTheSender - whenRatePerSecondNonZero + whenRatePerSecondIsNotZero whenRatePerSecondNotDifferent { vm.warp({ newTimestamp: WARP_ONE_MONTH }); diff --git a/test/integration/constructor.t.sol b/test/integration/constructor.t.sol index 52cd0f43..7a85a646 100644 --- a/test/integration/constructor.t.sol +++ b/test/integration/constructor.t.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.22; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; import { SablierV2OpenEnded } from "src/SablierV2OpenEnded.sol"; + import { Integration_Test } from "./Integration.t.sol"; contract Constructor_Integration_Concrete_Test is Integration_Test { @@ -9,6 +11,12 @@ contract Constructor_Integration_Concrete_Test is Integration_Test { // Construct the contract. SablierV2OpenEnded constructedOpenEnded = new SablierV2OpenEnded(); + // {SablierV2OpenEndedState.MAX_BROKER_FEE} + UD60x18 actualMaxBrokerFee = constructedOpenEnded.MAX_BROKER_FEE(); + UD60x18 expectedMaxBrokerFee = UD60x18.wrap(0.1e18); + assertEq(actualMaxBrokerFee, expectedMaxBrokerFee, "MAX_BROKER_FEE"); + + // {SablierV2OpenEndedState.nextStreamId} uint256 actualStreamId = constructedOpenEnded.nextStreamId(); uint256 expectedStreamId = 1; assertEq(actualStreamId, expectedStreamId, "nextStreamId"); diff --git a/test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.t.sol b/test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.t.sol new file mode 100644 index 00000000..a7c0601c --- /dev/null +++ b/test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.t.sol @@ -0,0 +1,124 @@ +// 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 { OpenEnded } from "src/types/DataTypes.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract CreateAndDepositViaBroker_Integration_Test is Integration_Test { + function test_RevertWhen_DelegateCalled() external { + bytes memory callData = abi.encodeCall( + ISablierV2OpenEnded.createAndDepositViaBroker, + ( + users.sender, + users.recipient, + RATE_PER_SECOND, + dai, + IS_TRANFERABLE, + DEPOSIT_AMOUNT_WITH_BROKER_FEE, + defaultBroker + ) + ); + // it should revert + expectRevertDueToDelegateCall(callData); + } + + function test_WhenNotDelegateCalled() external { + uint256 expectedStreamId = openEnded.nextStreamId(); + + // it should create the stream + // it should bump the next stream id + // it should mint the NFT + // it should update the stream balance + // it should perform the ERC20 transfers + // it should emit events: 1 {MetadataUpdate}, 1 {CreateOpenEndedStream}, 2 {Transfer}, 1 + // {DepositOpenEndedStream} + + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: expectedStreamId }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit CreateOpenEndedStream({ + streamId: expectedStreamId, + sender: users.sender, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + lastTimeUpdate: uint40(block.timestamp) + }); + + vm.expectEmit({ emitter: address(dai) }); + emit IERC20.Transfer({ + from: users.sender, + to: address(openEnded), + value: normalizeAmountToDecimal(DEPOSIT_AMOUNT, 18) + }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit DepositOpenEndedStream({ + streamId: expectedStreamId, + funder: users.sender, + asset: dai, + depositAmount: DEPOSIT_AMOUNT + }); + + vm.expectEmit({ emitter: address(dai) }); + emit IERC20.Transfer({ + from: users.sender, + to: users.broker, + value: normalizeAmountToDecimal(BROKER_FEE_AMOUNT, 18) + }); + + expectCallToTransferFrom({ + asset: dai, + from: users.sender, + to: address(openEnded), + amount: normalizeAmountToDecimal(DEPOSIT_AMOUNT, 18) + }); + + expectCallToTransferFrom({ + asset: dai, + from: users.sender, + to: users.broker, + amount: normalizeAmountToDecimal(BROKER_FEE_AMOUNT, 18) + }); + + uint256 actualStreamId = openEnded.createAndDepositViaBroker({ + sender: users.sender, + recipient: users.recipient, + ratePerSecond: RATE_PER_SECOND, + asset: dai, + isTransferable: IS_TRANFERABLE, + totalAmount: DEPOSIT_AMOUNT_WITH_BROKER_FEE, + broker: defaultBroker + }); + + OpenEnded.Stream memory actualStream = openEnded.getStream(actualStreamId); + OpenEnded.Stream memory expectedStream = OpenEnded.Stream({ + ratePerSecond: RATE_PER_SECOND, + asset: dai, + assetDecimals: 18, + balance: DEPOSIT_AMOUNT, + lastTimeUpdate: uint40(block.timestamp), + isPaused: false, + isStream: true, + isTransferable: IS_TRANFERABLE, + remainingAmount: 0, + sender: users.sender + }); + + assertEq(actualStreamId, expectedStreamId, "stream id"); + assertEq(actualStream, expectedStream); + + address actualNFTOwner = openEnded.ownerOf({ tokenId: actualStreamId }); + address expectedNFTOwner = users.recipient; + assertEq(actualNFTOwner, expectedNFTOwner, "NFT owner"); + + uint128 actualStreamBalance = openEnded.getBalance(expectedStreamId); + uint128 expectedStreamBalance = DEPOSIT_AMOUNT; + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + } +} diff --git a/test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.tree b/test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.tree new file mode 100644 index 00000000..b71cdd9f --- /dev/null +++ b/test/integration/create-and-deposit-via-broker/createAndDepositViaBroker.tree @@ -0,0 +1,10 @@ +CreateAndDepositViaBroker_Integration_Test +├── when delegate called +│ └── it should revert +└── when not delegate called + ├── it should create the stream + ├── it should bump the next stream id + ├── it should mint the NFT + ├── it should update the stream balance + ├── it should perform the ERC20 transfers + └── it should emit events: 1 {MetadataUpdate}, 1 {CreateOpenEndedStream}, 2 {Transfer}, 1 {DepositOpenEndedStream} \ No newline at end of file diff --git a/test/integration/create/create.t.sol b/test/integration/create/create.t.sol index 79d241a6..1dc6c4f3 100644 --- a/test/integration/create/create.t.sol +++ b/test/integration/create/create.t.sol @@ -33,7 +33,7 @@ contract Create_Integration_Test is Integration_Test { }); } - function test_RevertWhen_RecipientZeroAddress() external whenNotDelegateCalled whenSenderNonZeroAddress { + function test_RevertWhen_RecipientZeroAddress() external whenNotDelegateCalled whenSenderIsNotZeroAddress { vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721InvalidReceiver.selector, address(0))); openEnded.create({ sender: users.sender, @@ -47,8 +47,8 @@ contract Create_Integration_Test is Integration_Test { function test_RevertWhen_RatePerSecondZero() external whenNotDelegateCalled - whenSenderNonZeroAddress - whenRecipientNonZeroAddress + whenSenderIsNotZeroAddress + whenRecipientIsNotZeroAddress { vm.expectRevert(Errors.SablierV2OpenEnded_RatePerSecondZero.selector); openEnded.create({ @@ -63,9 +63,9 @@ contract Create_Integration_Test is Integration_Test { function test_RevertWhen_AssetNotContract() external whenNotDelegateCalled - whenSenderNonZeroAddress - whenRecipientNonZeroAddress - whenRatePerSecondNonZero + whenSenderIsNotZeroAddress + whenRecipientIsNotZeroAddress + whenRatePerSecondIsNotZero { address nonContract = address(8128); vm.expectRevert( @@ -83,9 +83,9 @@ contract Create_Integration_Test is Integration_Test { function test_Create() external whenNotDelegateCalled - whenSenderNonZeroAddress - whenRecipientNonZeroAddress - whenRatePerSecondNonZero + whenSenderIsNotZeroAddress + whenRecipientIsNotZeroAddress + whenRatePerSecondIsNotZero whenAssetContract { uint256 expectedStreamId = openEnded.nextStreamId(); diff --git a/test/integration/deposit-via-broker/depositViaBroker.t.sol b/test/integration/deposit-via-broker/depositViaBroker.t.sol new file mode 100644 index 00000000..027388f3 --- /dev/null +++ b/test/integration/deposit-via-broker/depositViaBroker.t.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ud } from "@prb/math/src/UD60x18.sol"; + +import { ISablierV2OpenEnded } from "src/interfaces/ISablierV2OpenEnded.sol"; +import { Errors } from "src/libraries/Errors.sol"; +import { Broker } from "src/types/DataTypes.sol"; + +import { Integration_Test } from "../Integration.t.sol"; + +contract DepositViaBroker_Integration_Test is Integration_Test { + function test_RevertWhen_DelegateCalled() external { + bytes memory callData = abi.encodeCall( + ISablierV2OpenEnded.depositViaBroker, (defaultStreamId, DEPOSIT_AMOUNT_WITH_BROKER_FEE, defaultBroker) + ); + // it should revert + expectRevertDueToDelegateCall(callData); + } + + function test_RevertGiven_Null() external whenNotDelegateCalled { + // it should revert + expectRevertNull(); + openEnded.depositViaBroker(nullStreamId, DEPOSIT_AMOUNT_WITH_BROKER_FEE, defaultBroker); + } + + function test_RevertWhen_BrokerFeeGreaterThanMaxFee() external whenNotDelegateCalled givenNotNull { + defaultBroker.fee = MAX_BROKER_FEE.add(ud(1)); + // it should revert + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierV2OpenEnded_BrokerFeeTooHigh.selector, defaultStreamId, defaultBroker.fee, MAX_BROKER_FEE + ) + ); + openEnded.depositViaBroker(defaultStreamId, DEPOSIT_AMOUNT_WITH_BROKER_FEE, defaultBroker); + } + + function test_RevertWhen_BrokeAddressIsZero() + external + whenNotDelegateCalled + givenNotNull + whenBrokerFeeNotGreaterThanMaxFee + { + defaultBroker.account = address(0); + // it should revert + vm.expectRevert(Errors.SablierV2OpenEnded_BrokerAddressZero.selector); + openEnded.depositViaBroker(defaultStreamId, DEPOSIT_AMOUNT_WITH_BROKER_FEE, defaultBroker); + } + + function test_RevertWhen_TotalAmountIsZero() + external + whenNotDelegateCalled + givenNotNull + whenBrokerFeeNotGreaterThanMaxFee + whenBrokerAddressIsNotZero + { + // it should revert + vm.expectRevert(Errors.SablierV2OpenEnded_DepositAmountZero.selector); + openEnded.depositViaBroker(defaultStreamId, 0, defaultBroker); + } + + function test_WhenAssetMissesERC20Return() + external + whenNotDelegateCalled + givenNotNull + whenBrokerFeeNotGreaterThanMaxFee + whenBrokerAddressIsNotZero + whenTotalAmountIsNotZero + { + // it should make the deposit + uint256 streamId = createDefaultStreamWithAsset(IERC20(address(usdt))); + _test_DepositViaBroker(streamId, IERC20(address(usdt)), defaultBroker); + } + + function test_GivenAssetDoesNotHave18Decimals() + external + whenNotDelegateCalled + givenNotNull + whenBrokerFeeNotGreaterThanMaxFee + whenBrokerAddressIsNotZero + whenTotalAmountIsNotZero + whenAssetDoesNotMissERC20Return + { + // it should update the stream balance + // it should perform the ERC20 transfer + // it should emit 2 {Transfer}, 1 {DepositOpenEndedStream}, 1 {MetadataUpdate} events + uint256 streamId = createDefaultStreamWithAsset(IERC20(address(usdc))); + _test_DepositViaBroker(streamId, IERC20(address(usdc)), defaultBroker); + } + + function test_GivenAssetHas18Decimals() + external + whenNotDelegateCalled + givenNotNull + whenBrokerFeeNotGreaterThanMaxFee + whenBrokerAddressIsNotZero + whenTotalAmountIsNotZero + whenAssetDoesNotMissERC20Return + { + // it should update the stream balance + // it should perform the ERC20 transfer + // it should emit 2 {Transfer}, 1 {DepositOpenEndedStream}, 1 {MetadataUpdate} events + uint256 streamId = createDefaultStreamWithAsset(IERC20(address(dai))); + _test_DepositViaBroker(streamId, IERC20(address(dai)), defaultBroker); + } + + function _test_DepositViaBroker(uint256 streamId, IERC20 asset, Broker memory broker) private { + vm.expectEmit({ emitter: address(asset) }); + emit IERC20.Transfer({ + from: users.sender, + to: address(openEnded), + value: normalizeAmountWithStreamId(streamId, DEPOSIT_AMOUNT) + }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit DepositOpenEndedStream({ + streamId: streamId, + funder: users.sender, + asset: asset, + depositAmount: DEPOSIT_AMOUNT + }); + + vm.expectEmit({ emitter: address(asset) }); + emit IERC20.Transfer({ + from: users.sender, + to: users.broker, + value: normalizeAmountWithStreamId(streamId, BROKER_FEE_AMOUNT) + }); + + vm.expectEmit({ emitter: address(openEnded) }); + emit MetadataUpdate({ _tokenId: streamId }); + + expectCallToTransferFrom({ + asset: asset, + from: users.sender, + to: address(openEnded), + amount: normalizeAmountWithStreamId(streamId, DEPOSIT_AMOUNT) + }); + + expectCallToTransferFrom({ + asset: asset, + from: users.sender, + to: users.broker, + amount: normalizeAmountWithStreamId(streamId, BROKER_FEE_AMOUNT) + }); + + openEnded.depositViaBroker(streamId, DEPOSIT_AMOUNT_WITH_BROKER_FEE, broker); + + uint128 actualStreamBalance = openEnded.getBalance(streamId); + uint128 expectedStreamBalance = DEPOSIT_AMOUNT; + assertEq(actualStreamBalance, expectedStreamBalance, "stream balance"); + } +} diff --git a/test/integration/deposit-via-broker/depositViaBroker.tree b/test/integration/deposit-via-broker/depositViaBroker.tree new file mode 100644 index 00000000..229ae699 --- /dev/null +++ b/test/integration/deposit-via-broker/depositViaBroker.tree @@ -0,0 +1,27 @@ +DepositViaBroker_Integration_Test +├── when delegate called +│ └── it should revert +└── when not delegate called + ├── given null + │ └── it should revert + └── given not null + ├── when broker fee greater than max fee + │ └── it should revert + └── when broker fee not greater than max fee + ├── when broke address is zero + │ └── it should revert + └── when broker address is not zero + ├── when total amount is zero + │ └── it should revert + └── when total amount is not zero + ├── when asset misses ERC20 return + │ └── it should make the deposit + └── when asset does not miss ERC20 return + ├── given asset does not have 18 decimals + │ ├── it should update the stream balance + │ ├── it should perform the ERC20 transfer + │ └── it should emit 2 {Transfer}, 1 {DepositOpenEndedStream}, 1 {MetadataUpdate} events + └── given asset has 18 decimals + ├── it should update the stream balance + ├── it should perform the ERC20 transfer + └── it should emit 2 {Transfer}, 1 {DepositOpenEndedStream}, 1 {MetadataUpdate} events diff --git a/test/integration/deposit/deposit.t.sol b/test/integration/deposit/deposit.t.sol index b05a85bf..5420a3f7 100644 --- a/test/integration/deposit/deposit.t.sol +++ b/test/integration/deposit/deposit.t.sol @@ -56,7 +56,7 @@ contract Deposit_Integration_Test is Integration_Test { emit IERC20.Transfer({ from: users.sender, to: address(openEnded), - value: normalizeTransferAmount(streamId, DEPOSIT_AMOUNT) + value: normalizeAmountWithStreamId(streamId, DEPOSIT_AMOUNT) }); vm.expectEmit({ emitter: address(openEnded) }); @@ -74,7 +74,7 @@ contract Deposit_Integration_Test is Integration_Test { asset: asset, from: users.sender, to: address(openEnded), - amount: normalizeTransferAmount(streamId, DEPOSIT_AMOUNT) + amount: normalizeAmountWithStreamId(streamId, DEPOSIT_AMOUNT) }); openEnded.deposit(streamId, DEPOSIT_AMOUNT); diff --git a/test/integration/pause/pause.t.sol b/test/integration/pause/pause.t.sol index c1128c87..ac7226d7 100644 --- a/test/integration/pause/pause.t.sol +++ b/test/integration/pause/pause.t.sol @@ -87,8 +87,7 @@ contract Pause_Integration_Test is Integration_Test { givenNotNull givenNotPaused whenCallerIsTheSender - givenWithdrawableAmountNotZero - givenRefundableAmountNotZero + givenStreamHasNoDebt { depositToDefaultStream(); diff --git a/test/integration/refund-from-stream/refundFromStream.t.sol b/test/integration/refund-from-stream/refundFromStream.t.sol index 42c6a942..a2b8b78d 100644 --- a/test/integration/refund-from-stream/refundFromStream.t.sol +++ b/test/integration/refund-from-stream/refundFromStream.t.sol @@ -120,7 +120,7 @@ contract RefundFromStream_Integration_Test is Integration_Test { emit IERC20.Transfer({ from: address(openEnded), to: users.sender, - value: normalizeTransferAmount(streamId, REFUND_AMOUNT) + value: normalizeAmountWithStreamId(streamId, REFUND_AMOUNT) }); vm.expectEmit({ emitter: address(openEnded) }); @@ -131,7 +131,11 @@ contract RefundFromStream_Integration_Test is Integration_Test { refundAmount: REFUND_AMOUNT }); - expectCallToTransfer({ asset: asset, to: users.sender, amount: normalizeTransferAmount(streamId, REFUND_AMOUNT) }); + expectCallToTransfer({ + asset: asset, + to: users.sender, + amount: normalizeAmountWithStreamId(streamId, REFUND_AMOUNT) + }); openEnded.refundFromStream({ streamId: streamId, amount: REFUND_AMOUNT }); uint128 actualStreamBalance = openEnded.getBalance(streamId); diff --git a/test/integration/restart-stream/restartStream.t.sol b/test/integration/restart-stream/restartStream.t.sol index 8f51c89d..e9f4bffc 100644 --- a/test/integration/restart-stream/restartStream.t.sol +++ b/test/integration/restart-stream/restartStream.t.sol @@ -74,7 +74,7 @@ contract RestartStream_Integration_Test is Integration_Test { givenNotNull givenPaused whenCallerIsTheSender - whenRatePerSecondNonZero + whenRatePerSecondIsNotZero { vm.expectEmit({ emitter: address(openEnded) }); emit RestartOpenEndedStream({ diff --git a/test/integration/withdraw-at/withdrawAt.t.sol b/test/integration/withdraw-at/withdrawAt.t.sol index 1c044a88..48ff1061 100644 --- a/test/integration/withdraw-at/withdrawAt.t.sol +++ b/test/integration/withdraw-at/withdrawAt.t.sol @@ -330,7 +330,7 @@ contract WithdrawAt_Integration_Test is Integration_Test { emit IERC20.Transfer({ from: address(openEnded), to: users.recipient, - value: normalizeTransferAmount(streamId, WITHDRAW_AMOUNT) + value: normalizeAmountWithStreamId(streamId, WITHDRAW_AMOUNT) }); vm.expectEmit({ emitter: address(openEnded) }); @@ -344,7 +344,7 @@ contract WithdrawAt_Integration_Test is Integration_Test { expectCallToTransfer({ asset: asset, to: users.recipient, - amount: normalizeTransferAmount(streamId, WITHDRAW_AMOUNT) + amount: normalizeAmountWithStreamId(streamId, WITHDRAW_AMOUNT) }); openEnded.withdrawAt({ streamId: streamId, to: users.recipient, time: WITHDRAW_TIME }); diff --git a/test/integration/withdraw-max/withdrawMax.t.sol b/test/integration/withdraw-max/withdrawMax.t.sol index 70818f03..b143eb96 100644 --- a/test/integration/withdraw-max/withdrawMax.t.sol +++ b/test/integration/withdraw-max/withdrawMax.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.22; +import { ISablierV2OpenEnded } from "src/interfaces/ISablierV2OpenEnded.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { Integration_Test } from "../Integration.t.sol"; @@ -14,6 +15,11 @@ contract WithdrawMax_Integration_Concrete_Test is Integration_Test { vm.warp({ newTimestamp: WARP_ONE_MONTH }); } + function test_RevertWhen_DelegateCall() external { + bytes memory callData = abi.encodeCall(ISablierV2OpenEnded.withdrawMax, (defaultStreamId, users.recipient)); + expectRevertDueToDelegateCall(callData); + } + function test_WithdrawMax_Paused() external { openEnded.pause(defaultStreamId); @@ -47,7 +53,7 @@ contract WithdrawMax_Integration_Concrete_Test is Integration_Test { emit IERC20.Transfer({ from: address(openEnded), to: users.recipient, - value: normalizeTransferAmount(defaultStreamId, ONE_MONTH_STREAMED_AMOUNT) + value: normalizeAmountWithStreamId(defaultStreamId, ONE_MONTH_STREAMED_AMOUNT) }); vm.expectEmit({ emitter: address(openEnded) }); diff --git a/test/integration/withdraw-max/withdrawMax.tree b/test/integration/withdraw-max/withdrawMax.tree index cd42fd91..9e8188ea 100644 --- a/test/integration/withdraw-max/withdrawMax.tree +++ b/test/integration/withdraw-max/withdrawMax.tree @@ -1,11 +1,14 @@ withdrawMax.t.sol -├── given the stream is paused -│ └── it should withdraw the remaining amount -└── given the end time is not paused - ├── it should make the max 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 {MetadataUpdated} event \ No newline at end of file +├── when delegate called +│ └── it should revert +└── when not delegate called + ├── given the stream is paused + │ └── it should withdraw the remaining amount + └── given the end time is not paused + ├── it should make the max 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 {MetadataUpdated} event \ No newline at end of file diff --git a/test/invariant/OpenEnded.t.sol b/test/invariant/OpenEnded.t.sol index c9956f75..55d81bd4 100644 --- a/test/invariant/OpenEnded.t.sol +++ b/test/invariant/OpenEnded.t.sol @@ -80,7 +80,7 @@ contract OpenEnded_Invariant_Test is Invariant_Test { uint256 streamBalancesSumNormalized; for (uint256 i = 0; i < lastStreamId; ++i) { uint256 streamId = openEndedStore.streamIds(i); - streamBalancesSumNormalized += uint256(normalizeBalance(streamId)); + streamBalancesSumNormalized += uint256(normalizeStreamBalance(streamId)); } assertGe( diff --git a/test/mocks/ERC20Mock.sol b/test/mocks/ERC20Mock.sol index 6886f324..bfac41c7 100644 --- a/test/mocks/ERC20Mock.sol +++ b/test/mocks/ERC20Mock.sol @@ -4,5 +4,13 @@ pragma solidity >=0.8.22; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract ERC20Mock is ERC20 { - constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) { } + uint8 internal immutable DECIMAL; + + constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { + DECIMAL = decimals_; + } + + function decimals() public view override returns (uint8) { + return DECIMAL; + } } diff --git a/test/utils/Assertions.sol b/test/utils/Assertions.sol index 6a28a598..1ea43fb1 100644 --- a/test/utils/Assertions.sol +++ b/test/utils/Assertions.sol @@ -29,6 +29,8 @@ abstract contract Assertions is PRBMathAssertions { assertEq(a.lastTimeUpdate, b.lastTimeUpdate, "lastTimeUpdate"); assertEq(a.isPaused, b.isPaused, "isPaused"); assertEq(a.isStream, b.isStream, "isStream"); + assertEq(a.isTransferable, b.isTransferable, "isTransferable"); + assertEq(a.remainingAmount, b.remainingAmount, "remainingAmount"); assertEq(a.sender, b.sender, "sender"); } } diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol index 73fdc11b..7a576f5e 100644 --- a/test/utils/Constants.sol +++ b/test/utils/Constants.sol @@ -1,10 +1,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.8.22; +import { UD60x18 } from "@prb/math/src/UD60x18.sol"; + abstract contract Constants { + UD60x18 public constant BROKER_FEE = UD60x18.wrap(0.01e18); // 1% + uint128 public constant BROKER_FEE_AMOUNT = 505.050505050505050505e18; // 1% of total amount uint128 public constant DEPOSIT_AMOUNT = 50_000e18; - uint128 public constant DEPOSIT_AMOUNT_WITH_FEE = 50_251.256281407035175879e18; // deposit + broker fee + uint128 public constant DEPOSIT_AMOUNT_WITH_BROKER_FEE = 50_505.050505050505050505e18; // deposit + broker fee bool public constant IS_TRANFERABLE = true; + UD60x18 internal constant MAX_BROKER_FEE = UD60x18.wrap(0.1e18); // 10% uint40 internal constant MAY_1_2024 = 1_714_518_000; uint40 public immutable ONE_MONTH = 30 days; // "30/360" convention uint128 public constant ONE_MONTH_STREAMED_AMOUNT = 2592e18; // 86.4 * 30 diff --git a/test/utils/Modifiers.sol b/test/utils/Modifiers.sol index 7850d7da..dbc25f51 100644 --- a/test/utils/Modifiers.sol +++ b/test/utils/Modifiers.sol @@ -22,15 +22,23 @@ abstract contract Modifiers { _; } + modifier givenRemainingAmountNotZero() { + _; + } + modifier givenRemainingAmountZero() { _; } - modifier givenRemainingAmountNotZero() { + modifier whenAssetDoesNotMissERC20Return() { _; } - modifier whenCallerIsTheSender() { + modifier whenBrokerAddressIsNotZero() { + _; + } + + modifier whenBrokerFeeNotGreaterThanMaxFee() { _; } @@ -38,11 +46,27 @@ abstract contract Modifiers { _; } + modifier whenCallerIsTheSender() { + _; + } + modifier whenNotDelegateCalled() { _; } - modifier whenRatePerSecondNonZero() { + modifier whenRatePerSecondIsNotZero() { + _; + } + + modifier whenSenderIsNotZeroAddress() { + _; + } + + modifier whenTokenDecimalIsNotZero() { + _; + } + + modifier whenTotalAmountIsNotZero() { _; } @@ -55,14 +79,10 @@ abstract contract Modifiers { } /*////////////////////////////////////////////////////////////////////////// - CANCEL + PAUSE //////////////////////////////////////////////////////////////////////////*/ - modifier givenRefundableAmountNotZero() { - _; - } - - modifier givenWithdrawableAmountNotZero() { + modifier givenStreamHasNoDebt() { _; } @@ -74,11 +94,7 @@ abstract contract Modifiers { _; } - modifier whenRecipientNonZeroAddress() { - _; - } - - modifier whenSenderNonZeroAddress() { + modifier whenRecipientIsNotZeroAddress() { _; }