diff --git a/foundry.toml b/foundry.toml index 8eca8d11..8d5011e4 100644 --- a/foundry.toml +++ b/foundry.toml @@ -21,7 +21,7 @@ [profile.default.fuzz] max_test_rejects = 1_000_000 # Number of times `vm.assume` can fail - runs = 10000 + runs = 10 [profile.default.invariant] call_override = false # Override unsafe external calls to perform reentrancy checks diff --git a/src/SablierFlow.sol b/src/SablierFlow.sol index ea5c7cd1..350101a7 100644 --- a/src/SablierFlow.sol +++ b/src/SablierFlow.sol @@ -191,6 +191,7 @@ contract SablierFlow is UD21x18 newRatePerSecond ) external + payable override noDelegateCall notNull(streamId) @@ -221,6 +222,7 @@ contract SablierFlow is bool transferable ) external + payable override noDelegateCall returns (uint256 streamId) @@ -239,6 +241,7 @@ contract SablierFlow is uint128 amount ) external + payable override noDelegateCall returns (uint256 streamId) @@ -258,6 +261,7 @@ contract SablierFlow is address recipient ) external + payable override noDelegateCall notNull(streamId) @@ -277,6 +281,7 @@ contract SablierFlow is uint128 amount ) external + payable override noDelegateCall notNull(streamId) @@ -300,6 +305,7 @@ contract SablierFlow is Broker calldata broker ) external + payable override noDelegateCall notNull(streamId) @@ -316,6 +322,7 @@ contract SablierFlow is /// @inheritdoc ISablierFlow function pause(uint256 streamId) external + payable override noDelegateCall notNull(streamId) @@ -333,6 +340,7 @@ contract SablierFlow is uint128 amount ) external + payable override noDelegateCall notNull(streamId) @@ -349,6 +357,7 @@ contract SablierFlow is uint128 amount ) external + payable override noDelegateCall notNull(streamId) @@ -366,6 +375,7 @@ contract SablierFlow is /// @inheritdoc ISablierFlow function refundMax(uint256 streamId) external + payable override noDelegateCall notNull(streamId) @@ -384,6 +394,7 @@ contract SablierFlow is UD21x18 ratePerSecond ) external + payable override noDelegateCall notNull(streamId) @@ -402,6 +413,7 @@ contract SablierFlow is uint128 amount ) external + payable override noDelegateCall notNull(streamId) @@ -419,6 +431,7 @@ contract SablierFlow is /// @inheritdoc ISablierFlow function void(uint256 streamId) external + payable override noDelegateCall notNull(streamId) @@ -436,6 +449,7 @@ contract SablierFlow is uint128 amount ) external + payable override noDelegateCall notNull(streamId) @@ -452,6 +466,7 @@ contract SablierFlow is address to ) external + payable override noDelegateCall notNull(streamId) diff --git a/src/abstracts/Batch.sol b/src/abstracts/Batch.sol index 47a86d48..d6d2f7e0 100644 --- a/src/abstracts/Batch.sol +++ b/src/abstracts/Batch.sol @@ -13,7 +13,7 @@ abstract contract Batch is IBatch { //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc IBatch - function batch(bytes[] calldata calls) external override { + function batch(bytes[] calldata calls) external payable override { uint256 count = calls.length; for (uint256 i = 0; i < count; ++i) { diff --git a/src/abstracts/SablierFlowBase.sol b/src/abstracts/SablierFlowBase.sol index 3770fba7..9cde2755 100644 --- a/src/abstracts/SablierFlowBase.sol +++ b/src/abstracts/SablierFlowBase.sol @@ -205,6 +205,22 @@ abstract contract SablierFlowBase is USER-FACING NON-CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ + /// @inheritdoc ISablierFlowBase + function collectFees() external override { + uint256 feeAmount = address(this).balance; + + // Effect: transfer the fees to the admin. + (bool success,) = admin.call{ value: feeAmount }(""); + + // Revert if the call failed. + if (!success) { + revert Errors.SablierFlowBase_FeeTransferFail(admin, feeAmount); + } + + // Log the fee withdrawal. + emit ISablierFlowBase.CollectFees({ admin: admin, feeAmount: feeAmount }); + } + /// @inheritdoc ISablierFlowBase function collectProtocolRevenue(IERC20 token, address to) external override onlyAdmin { uint128 revenue = protocolRevenue[token]; diff --git a/src/interfaces/IBatch.sol b/src/interfaces/IBatch.sol index b4125adb..862762ef 100644 --- a/src/interfaces/IBatch.sol +++ b/src/interfaces/IBatch.sol @@ -5,5 +5,5 @@ pragma solidity >=0.8.22; interface IBatch { /// @notice Allows batched call to self, `this` contract. /// @param calls An array of inputs for each call. - function batch(bytes[] calldata calls) external; + function batch(bytes[] calldata calls) external payable; } diff --git a/src/interfaces/ISablierFlow.sol b/src/interfaces/ISablierFlow.sol index bb2c5a08..593cb8cb 100644 --- a/src/interfaces/ISablierFlow.sol +++ b/src/interfaces/ISablierFlow.sol @@ -178,7 +178,7 @@ interface ISablierFlow is /// @param streamId The ID of the stream to adjust. /// @param newRatePerSecond The new rate per second, denoted as a fixed-point number where 1e18 is 1 token /// per second. - function adjustRatePerSecond(uint256 streamId, UD21x18 newRatePerSecond) external; + function adjustRatePerSecond(uint256 streamId, UD21x18 newRatePerSecond) external payable; /// @notice Creates a new Flow stream by setting the snapshot time to `block.timestamp` and leaving the balance to /// zero. The stream is wrapped in an ERC-721 NFT. @@ -208,6 +208,7 @@ interface ISablierFlow is bool transferable ) external + payable returns (uint256 streamId); /// @notice Creates a new Flow stream by setting the snapshot time to `block.timestamp` and the balance to `amount`. @@ -239,6 +240,7 @@ interface ISablierFlow is uint128 amount ) external + payable returns (uint256 streamId); /// @notice Makes a deposit in a stream. @@ -255,7 +257,7 @@ interface ISablierFlow is /// @param amount The deposit amount, denoted in token's decimals. /// @param sender The stream's sender address. /// @param recipient The stream's recipient address. - function deposit(uint256 streamId, uint128 amount, address sender, address recipient) external; + function deposit(uint256 streamId, uint128 amount, address sender, address recipient) external payable; /// @notice Deposits tokens in a stream and pauses it. /// @@ -269,7 +271,7 @@ interface ISablierFlow is /// /// @param streamId The ID of the stream to deposit to, and then pause. /// @param amount The deposit amount, denoted in token's decimals. - function depositAndPause(uint256 streamId, uint128 amount) external; + function depositAndPause(uint256 streamId, uint128 amount) external payable; /// @notice Deposits tokens in a stream. /// @@ -298,7 +300,8 @@ interface ISablierFlow is address recipient, Broker calldata broker ) - external; + external + payable; /// @notice Pauses the stream. /// @@ -314,7 +317,7 @@ interface ISablierFlow is /// - `msg.sender` must be the stream's sender. /// /// @param streamId The ID of the stream to pause. - function pause(uint256 streamId) external; + function pause(uint256 streamId) external payable; /// @notice Refunds the provided amount of tokens from the stream to the sender's address. /// @@ -328,7 +331,7 @@ interface ISablierFlow is /// /// @param streamId The ID of the stream to refund from. /// @param amount The amount to refund, denoted in token's decimals. - function refund(uint256 streamId, uint128 amount) external; + function refund(uint256 streamId, uint128 amount) external payable; /// @notice Refunds the provided amount of tokens from the stream to the sender's address. /// @@ -342,7 +345,7 @@ interface ISablierFlow is /// /// @param streamId The ID of the stream to refund from and then pause. /// @param amount The amount to refund, denoted in token's decimals. - function refundAndPause(uint256 streamId, uint128 amount) external; + function refundAndPause(uint256 streamId, uint128 amount) external payable; /// @notice Refunds the entire refundable amount of tokens from the stream to the sender's address. /// @@ -352,7 +355,7 @@ interface ISablierFlow is /// - Refer to the requirements in {refund}. /// /// @param streamId The ID of the stream to refund from. - function refundMax(uint256 streamId) external; + function refundMax(uint256 streamId) external payable; /// @notice Restarts the stream with the provided rate per second. /// @@ -370,7 +373,7 @@ interface ISablierFlow is /// @param streamId The ID of the stream to restart. /// @param ratePerSecond The amount by which the debt is increasing every second, denoted as a fixed-point number /// where 1e18 is 1 token per second. - function restart(uint256 streamId, UD21x18 ratePerSecond) external; + function restart(uint256 streamId, UD21x18 ratePerSecond) external payable; /// @notice Restarts the stream with the provided rate per second, and makes a deposit. /// @@ -387,7 +390,7 @@ interface ISablierFlow is /// @param ratePerSecond The amount by which the debt is increasing every second, denoted as a fixed-point number /// where 1e18 is 1 token per second. /// @param amount The deposit amount, denoted in token's decimals. - function restartAndDeposit(uint256 streamId, UD21x18 ratePerSecond, uint128 amount) external; + function restartAndDeposit(uint256 streamId, UD21x18 ratePerSecond, uint128 amount) external payable; /// @notice Voids a stream. /// @@ -407,7 +410,7 @@ interface ISablierFlow is /// - `msg.sender` must either be the stream's sender, recipient or an approved third party. /// /// @param streamId The ID of the stream to void. - function void(uint256 streamId) external; + function void(uint256 streamId) external payable; /// @notice Withdraws the provided `amount` minus the protocol fee to the provided `to` address. /// @@ -436,6 +439,7 @@ interface ISablierFlow is uint128 amount ) external + payable returns (uint128 withdrawnAmount, uint128 protocolFeeAmount); /// @notice Withdraws the entire withdrawable amount minus the protocol fee to the provided `to` address. @@ -458,5 +462,6 @@ interface ISablierFlow is address to ) external + payable returns (uint128 withdrawnAmount, uint128 protocolFeeAmount); } diff --git a/src/interfaces/ISablierFlowBase.sol b/src/interfaces/ISablierFlowBase.sol index 164d7857..16de29cb 100644 --- a/src/interfaces/ISablierFlowBase.sol +++ b/src/interfaces/ISablierFlowBase.sol @@ -19,6 +19,11 @@ interface ISablierFlowBase is IERC721Metadata, // 2 inherited components IAdminable // 0 inherited components { + /// @notice Emitted when the accrued fees are collected. + /// @param admin The address of the current contract admin, which has received the fees. + /// @param feeAmount The amount of collected fees. + event CollectFees(address indexed admin, uint256 indexed feeAmount); + /// @notice Emitted when the contract admin collects protocol revenue accrued. /// @param admin The address of the contract admin. /// @param token The address of the ERC-20 token the protocol revenue has been collected for. @@ -145,6 +150,14 @@ interface ISablierFlowBase is NON-CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ + /// @notice Collects the accrued fees by transferring them to the contract admin. + /// + /// @dev Emits a {CollectFees} event. + /// + /// Notes: + /// - If the admin is a contract, it must be able to receive ETH. + function collectFees() external; + /// @notice Collect the protocol revenue accrued for the provided ERC-20 token. /// /// @dev Emits {CollectProtocolRevenue} event. diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 080bcd3c..2b2821d8 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -91,6 +91,9 @@ library Errors { SABLIER-FLOW-BASE //////////////////////////////////////////////////////////////////////////*/ + /// @notice Thrown when the fee transfer fails. + error SablierFlowBase_FeeTransferFail(address admin, uint256 feeAmount); + /// @notice Thrown when trying to claim protocol revenue when the accrued amount is zero. error SablierFlowBase_NoProtocolRevenue(address token); diff --git a/tests/Base.t.sol b/tests/Base.t.sol index f89e951b..9628702c 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -8,6 +8,7 @@ import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; import { SablierFlow } from "src/SablierFlow.sol"; import { ERC20MissingReturn } from "./mocks/ERC20MissingReturn.sol"; import { ERC20Mock } from "./mocks/ERC20Mock.sol"; +import { ContractWithoutReceive, ContractWithReceive } from "./mocks/Receive.sol"; import { Assertions } from "./utils/Assertions.sol"; import { Modifiers } from "./utils/Modifiers.sol"; import { Users } from "./utils/Types.sol"; @@ -25,9 +26,11 @@ abstract contract Base_Test is Assertions, Modifiers, Test { TEST CONTRACTS //////////////////////////////////////////////////////////////////////////*/ + ContractWithoutReceive internal contractWithoutReceive; + ContractWithReceive internal contractWithReceive; + ERC20Mock internal dai; ERC20Mock internal tokenWithoutDecimals; ERC20Mock internal tokenWithProtocolFee; - ERC20Mock internal dai; ERC20Mock internal usdc; ERC20MissingReturn internal usdt; @@ -48,8 +51,13 @@ abstract contract Base_Test is Assertions, Modifiers, Test { flow = deployOptimizedSablierFlow(); } - // Label the flow contract. - vm.label(address(flow), "Flow"); + contractWithoutReceive = new ContractWithoutReceive(); + contractWithReceive = new ContractWithReceive(); + + // Label the contracts. + vm.label({ account: address(flow), newLabel: "Flow" }); + vm.label({ account: address(contractWithoutReceive), newLabel: "Contract without Receive" }); + vm.label({ account: address(contractWithReceive), newLabel: "Contract with Receive" }); // Create new tokens and label them. createAndLabelTokens(); diff --git a/tests/fork/Flow.t.sol b/tests/fork/Flow.t.sol index 7229a229..fedda276 100644 --- a/tests/fork/Flow.t.sol +++ b/tests/fork/Flow.t.sol @@ -148,6 +148,9 @@ contract Flow_Fork_Test is Fork_Test { ) private { + uint256 initialFlowBalance = address(flow).balance; + + // Each function is going to pay a fee. if (flowFunc == FlowFunc.adjustRatePerSecond) { _test_AdjustRatePerSecond(streamId, ratePerSecond); } else if (flowFunc == FlowFunc.deposit) { @@ -163,6 +166,10 @@ contract Flow_Fork_Test is Fork_Test { } else if (flowFunc == FlowFunc.withdraw) { _test_Withdraw(streamId, withdrawAmount); } + + // Assert that the flow balance has changed. + uint256 expectedFlowBalance = initialFlowBalance + FEE; + assertEq(address(flow).balance, expectedFlowBalance, "Flow balance"); } /// @notice Find the first non-voided stream ID with the same token. @@ -217,11 +224,15 @@ contract Flow_Fork_Test is Fork_Test { ); // Make sure the requirements are respected. - resetPrank({ msgSender: flow.getSender(streamId) }); + address sender = flow.getSender(streamId); + resetPrank({ msgSender: sender }); if (flow.isPaused(streamId)) { flow.restart(streamId, RATE_PER_SECOND); } + // Fund the sender to pay the fee. + vm.deal({ account: sender, newBalance: sender.balance + FEE }); + UD21x18 oldRatePerSecond = flow.getRatePerSecond(streamId); if (newRatePerSecond.unwrap() == oldRatePerSecond.unwrap()) { newRatePerSecond = ud21x18(newRatePerSecond.unwrap() + 1); @@ -247,7 +258,7 @@ contract Flow_Fork_Test is Fork_Test { vm.expectEmit({ emitter: address(flow) }); emit IERC4906.MetadataUpdate({ _tokenId: streamId }); - flow.adjustRatePerSecond({ streamId: streamId, newRatePerSecond: newRatePerSecond }); + flow.adjustRatePerSecond{ value: FEE }({ streamId: streamId, newRatePerSecond: newRatePerSecond }); // It should update snapshot debt. vars.actualSnapshotDebtScaled = flow.getSnapshotDebtScaled(streamId); @@ -287,7 +298,10 @@ contract Flow_Fork_Test is Fork_Test { transferable: transferable }); - vars.actualStreamId = flow.create({ + resetPrank({ msgSender: sender }); + vm.deal({ account: sender, newBalance: sender.balance + FEE }); + + vars.actualStreamId = flow.create{ value: FEE }({ recipient: recipient, sender: sender, ratePerSecond: ratePerSecond, @@ -341,6 +355,7 @@ contract Flow_Fork_Test is Fork_Test { address sender = flow.getSender(streamId); resetPrank({ msgSender: sender }); deal({ token: address(token), to: sender, give: depositAmount }); + vm.deal({ account: sender, newBalance: sender.balance + FEE }); safeApprove(depositAmount); // Expect the relevant events to be emitted. @@ -357,7 +372,7 @@ contract Flow_Fork_Test is Fork_Test { expectCallToTransferFrom({ token: token, from: sender, to: address(flow), amount: depositAmount }); // Make the deposit. - flow.deposit(streamId, depositAmount, sender, flow.getRecipient(streamId)); + flow.deposit{ value: FEE }(streamId, depositAmount, sender, flow.getRecipient(streamId)); // Assert that the token balance of stream has been updated. vars.actualTokenBalance = token.balanceOf(address(flow)); @@ -381,11 +396,15 @@ contract Flow_Fork_Test is Fork_Test { function _test_Pause(uint256 streamId) private { // Make sure the requirements are respected. - resetPrank({ msgSender: flow.getSender(streamId) }); + address sender = flow.getSender(streamId); + resetPrank({ msgSender: sender }); if (flow.isPaused(streamId)) { flow.restart(streamId, RATE_PER_SECOND); } + // Fund the sender to pay the fee. + vm.deal({ account: sender, newBalance: sender.balance + FEE }); + // Expect the relevant events to be emitted. vm.expectEmit({ emitter: address(flow) }); emit ISablierFlow.PauseFlowStream({ @@ -399,7 +418,7 @@ contract Flow_Fork_Test is Fork_Test { emit IERC4906.MetadataUpdate({ _tokenId: streamId }); // Pause the stream. - flow.pause(streamId); + flow.pause{ value: FEE }(streamId); // Assert that the stream is paused. assertTrue(flow.isPaused(streamId), "Pause: paused"); @@ -424,6 +443,9 @@ contract Flow_Fork_Test is Fork_Test { depositOnStream(streamId, depositAmount); } + // Fund the sender to pay the fee. + vm.deal({ account: sender, newBalance: sender.balance + FEE }); + // Bound the refund amount to avoid error. refundAmount = boundUint128(refundAmount, 1, flow.refundableAmountOf(streamId)); @@ -442,7 +464,7 @@ contract Flow_Fork_Test is Fork_Test { emit IERC4906.MetadataUpdate({ _tokenId: streamId }); // Request the refund. - flow.refund(streamId, refundAmount); + flow.refund{ value: FEE }(streamId, refundAmount); // Assert that the token balance of stream has been updated. vars.actualTokenBalance = token.balanceOf(address(flow)); @@ -468,6 +490,10 @@ contract Flow_Fork_Test is Fork_Test { // Make sure the requirements are respected. address sender = flow.getSender(streamId); resetPrank({ msgSender: sender }); + + // Fund the sender to pay the fee. + vm.deal({ account: sender, newBalance: sender.balance + FEE }); + if (!flow.isPaused(streamId)) { flow.pause(streamId); } @@ -482,7 +508,7 @@ contract Flow_Fork_Test is Fork_Test { vm.expectEmit({ emitter: address(flow) }); emit IERC4906.MetadataUpdate({ _tokenId: streamId }); - flow.restart({ streamId: streamId, ratePerSecond: ratePerSecond }); + flow.restart{ value: FEE }({ streamId: streamId, ratePerSecond: ratePerSecond }); // It should restart the stream. assertFalse(flow.isPaused(streamId)); @@ -509,6 +535,9 @@ contract Flow_Fork_Test is Fork_Test { resetPrank({ msgSender: sender }); + // Fund the sender to pay the fee. + vm.deal({ account: sender, newBalance: sender.balance + FEE }); + if (uncoveredDebt > 0) { expectedTotalDebt = flow.getBalance(streamId); } else { @@ -529,7 +558,7 @@ contract Flow_Fork_Test is Fork_Test { vm.expectEmit({ emitter: address(flow) }); emit IERC4906.MetadataUpdate({ _tokenId: streamId }); - flow.void(streamId); + flow.void{ value: FEE }(streamId); // It should set the rate per second to zero. assertEq(flow.getRatePerSecond(streamId), 0, "Void: rate per second"); @@ -569,6 +598,7 @@ contract Flow_Fork_Test is Fork_Test { (, address caller,) = vm.readCallers(); address recipient = flow.getRecipient(streamId); + vm.deal({ account: caller, newBalance: caller.balance + FEE }); vars.expectedAggregateAmount = flow.aggregateBalance(token) - withdrawAmount; @@ -590,7 +620,7 @@ contract Flow_Fork_Test is Fork_Test { emit IERC4906.MetadataUpdate({ _tokenId: streamId }); // Withdraw the tokens. - flow.withdraw(streamId, recipient, withdrawAmount); + flow.withdraw{ value: FEE }(streamId, recipient, withdrawAmount); // It should update snapshot time. vars.actualSnapshotTime = flow.getSnapshotTime(streamId); diff --git a/tests/fork/Fork.t.sol b/tests/fork/Fork.t.sol index 41b8fac5..bedc222f 100644 --- a/tests/fork/Fork.t.sol +++ b/tests/fork/Fork.t.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.22; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ISablierFlow } from "src/interfaces/ISablierFlow.sol"; +import { SablierFlow } from "src/SablierFlow.sol"; import { Base_Test } from "../Base.t.sol"; @@ -41,8 +42,9 @@ abstract contract Fork_Test is Base_Test { // Fork Ethereum Mainnet at a block number after the Sablier deployment. vm.createSelectFork({ blockNumber: 21_330_578, urlOrAlias: "mainnet" }); - // Load mainnet address of flow. - flow = ISablierFlow(0x2D9221a63E12AA796619cb381Ec4A71b201281f5); + // TODO: update the flow address once deployed. + // flow = ISablierFlow(0x2D9221a63E12AA796619cb381Ec4A71b201281f5); + flow = new SablierFlow(users.admin, nftDescriptor); // Label the flow contract. vm.label(address(flow), "Flow"); @@ -91,6 +93,7 @@ abstract contract Fork_Test is Base_Test { address sender = flow.getSender(streamId); resetPrank({ msgSender: sender }); deal({ token: address(token), to: sender, give: depositAmount }); + vm.deal({ account: sender, newBalance: sender.balance + FEE }); safeApprove(depositAmount); flow.deposit({ streamId: streamId, diff --git a/tests/integration/concrete/collect-fees/collectFees.t.sol b/tests/integration/concrete/collect-fees/collectFees.t.sol new file mode 100644 index 00000000..b42a64d9 --- /dev/null +++ b/tests/integration/concrete/collect-fees/collectFees.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +import { ISablierFlowBase } from "src/interfaces/ISablierFlowBase.sol"; +import { Errors } from "src/libraries/Errors.sol"; + +import { Shared_Integration_Concrete_Test } from "../Concrete.t.sol"; + +contract CollectFees_Integration_Concrete_Test is Shared_Integration_Concrete_Test { + function setUp() public override { + Shared_Integration_Concrete_Test.setUp(); + depositToDefaultStream(); + } + + function test_WhenAdminIsNotContract() external { + _test_CollectFees(users.admin); + } + + function test_RevertWhen_AdminDoesNotImplementReceiveFunction() external whenAdminIsContract { + // Transfer the admin to a contract that does not implement the receive function. + resetPrank({ msgSender: users.admin }); + flow.transferAdmin(address(contractWithoutReceive)); + + // Make the contract the caller. + resetPrank({ msgSender: address(contractWithoutReceive) }); + + // Expect a revert. + vm.expectRevert( + abi.encodeWithSelector( + Errors.SablierFlowBase_FeeTransferFail.selector, address(contractWithoutReceive), address(flow).balance + ) + ); + + // Collect the fees. + flow.collectFees(); + } + + function test_WhenAdminImplementsReceiveFunction() external whenAdminIsContract { + // Transfer the admin to a contract that implements the receive function. + resetPrank({ msgSender: users.admin }); + flow.transferAdmin(address(contractWithReceive)); + + // Make the contract the caller. + resetPrank({ msgSender: address(contractWithReceive) }); + + // Run the tests. + _test_CollectFees(address(contractWithReceive)); + } + + function _test_CollectFees(address admin) private { + vm.warp({ newTimestamp: WITHDRAW_TIME }); + + // Load the initial ETH balance of the admin. + uint256 initialAdminBalance = admin.balance; + + // Make recipient the caller. + resetPrank({ msgSender: users.recipient }); + + // Make a withdrawal and pay the fee. + flow.withdrawMax{ value: FEE }({ streamId: defaultStreamId, to: users.recipient }); + + // It should emit a {CollectFees} event. + vm.expectEmit({ emitter: address(flow) }); + emit ISablierFlowBase.CollectFees({ admin: admin, feeAmount: FEE }); + + flow.collectFees(); + + // It should transfer the fee. + assertEq(admin.balance, initialAdminBalance + FEE, "admin ETH balance"); + + // It should decrease contract balance to zero. + assertEq(address(flow).balance, 0, "flow ETH balance"); + } +} diff --git a/tests/integration/concrete/collect-fees/collectFees.tree b/tests/integration/concrete/collect-fees/collectFees.tree new file mode 100644 index 00000000..6306e3a9 --- /dev/null +++ b/tests/integration/concrete/collect-fees/collectFees.tree @@ -0,0 +1,12 @@ +CollectFees_Integration_Concrete_Test +├── when admin is not contract +│ ├── it should transfer fee +│ ├── it should decrease contract balance to zero +│ └── it should emit a {CollectFees} event +└── when admin is contract + ├── when admin does not implement receive function + │ └── it should revert + └── when admin implements receive function + ├── it should transfer fee + ├── it should decrease contract balance to zero + └── it should emit a {CollectFees} event \ No newline at end of file diff --git a/tests/mocks/Receive.sol b/tests/mocks/Receive.sol new file mode 100644 index 00000000..31a8fa96 --- /dev/null +++ b/tests/mocks/Receive.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22 <0.9.0; + +contract ContractWithoutReceive { } + +contract ContractWithReceive { + receive() external payable { } +} diff --git a/tests/utils/Constants.sol b/tests/utils/Constants.sol index 35ed05e9..27e18fb2 100644 --- a/tests/utils/Constants.sol +++ b/tests/utils/Constants.sol @@ -8,6 +8,7 @@ abstract contract Constants { // Amounts uint128 internal constant DEPOSIT_AMOUNT_18D = 50_000e18; uint128 internal constant DEPOSIT_AMOUNT_6D = 50_000e6; + uint256 internal constant FEE = 0.001e18; uint128 internal constant REFUND_AMOUNT_18D = 10_000e18; uint128 internal constant REFUND_AMOUNT_6D = 10_000e6; uint128 internal constant TOTAL_AMOUNT_WITH_BROKER_FEE_18D = DEPOSIT_AMOUNT_18D + BROKER_FEE_AMOUNT_18D; diff --git a/tests/utils/Modifiers.sol b/tests/utils/Modifiers.sol index b75679ee..84f4b0e5 100644 --- a/tests/utils/Modifiers.sol +++ b/tests/utils/Modifiers.sol @@ -64,6 +64,14 @@ abstract contract Modifiers is Utils { _; } + /*////////////////////////////////////////////////////////////////////////// + COLLECT-FEES + //////////////////////////////////////////////////////////////////////////*/ + + modifier whenAdminIsContract() { + _; + } + /*////////////////////////////////////////////////////////////////////////// CREATE //////////////////////////////////////////////////////////////////////////*/