From 0835718468eabd59814d12bb94f363d265eac0b1 Mon Sep 17 00:00:00 2001 From: smol-ninja Date: Fri, 24 May 2024 12:16:36 +0100 Subject: [PATCH] feat: implement `getDepletionTime()` (#118) * feat: function depletionTimeOf() * test: rename defaultDeposit to depositToDefaultStream * refactor: use uint40 for the return value of depletionTimeOf refactor: improve readability in depletionTimeOf --------- Co-authored-by: andreivladbrg --- src/SablierV2OpenEnded.sol | 32 ++++++++++++++ src/interfaces/ISablierV2OpenEnded.sol | 11 +++++ test/integration/Integration.t.sol | 8 +--- .../depletion-time-of/depletionTimeOf.t.sol | 42 +++++++++++++++++++ .../depletion-time-of/depletionTimeOf.tree | 14 +++++++ test/integration/pause/pause.t.sol | 2 +- .../refund-from-stream/refundFromStream.t.sol | 2 +- .../refundableAmountOf.t.sol | 4 +- .../stream-debt-of/streamDebtOf.t.sol | 4 +- test/integration/withdraw-at/withdrawAt.t.sol | 2 +- .../withdraw-max/withdrawMax.t.sol | 2 +- .../withdrawableAmountOf.t.sol | 4 +- test/utils/Constants.sol | 1 + 13 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 test/integration/depletion-time-of/depletionTimeOf.t.sol create mode 100644 test/integration/depletion-time-of/depletionTimeOf.tree diff --git a/src/SablierV2OpenEnded.sol b/src/SablierV2OpenEnded.sol index d91984e5..f8b5c0d5 100644 --- a/src/SablierV2OpenEnded.sol +++ b/src/SablierV2OpenEnded.sol @@ -33,6 +33,38 @@ contract SablierV2OpenEnded is CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ + /// @inheritdoc ISablierV2OpenEnded + function depletionTimeOf(uint256 streamId) + external + view + override + notNull(streamId) + notPaused(streamId) + returns (uint40 depletionTime) + { + uint128 balance = _streams[streamId].balance; + + // If the stream balance is zero, return zero. + if (balance == 0) { + return 0; + } + + // Calculate here the recipient amount for gas optimization. + uint128 recipientAmount = + _streams[streamId].remainingAmount + _streamedAmountOf(streamId, uint40(block.timestamp)); + + // If the stream has debt, return zero. + if (recipientAmount >= balance) { + return 0; + } + + // Safe to unchecked because subtraction cannot underflow. + unchecked { + uint128 solvencyPeriod = (balance - recipientAmount) / _streams[streamId].ratePerSecond; + depletionTime = uint40(block.timestamp + solvencyPeriod); + } + } + /// @inheritdoc ISablierV2OpenEnded function refundableAmountOf(uint256 streamId) external diff --git a/src/interfaces/ISablierV2OpenEnded.sol b/src/interfaces/ISablierV2OpenEnded.sol index f14dec17..89c615b9 100644 --- a/src/interfaces/ISablierV2OpenEnded.sol +++ b/src/interfaces/ISablierV2OpenEnded.sol @@ -94,6 +94,17 @@ interface ISablierV2OpenEnded is CONSTANT FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ + /// @notice Returns the timestamp at which the stream depletes its balance and starts to accumulate debt. + /// @dev Reverts if `streamId` refers to a paused or a null stream. + /// + /// Notes: + /// - If the stream has no debt, it returns the timestamp when the debt begins based on current balance and rps. + /// - If the stream has debt, it returns 0. + /// + /// @param streamId The stream ID for the query. + /// @return depletionTime The UNIX timestamp. + function depletionTimeOf(uint256 streamId) external view returns (uint40 depletionTime); + /// @notice Calculates the amount that the sender can refund from stream, denoted in 18 decimals. /// @dev Reverts if `streamId` references a null stream. /// @param streamId The stream ID for the query. diff --git a/test/integration/Integration.t.sol b/test/integration/Integration.t.sol index 104e7bd1..305ac5db 100644 --- a/test/integration/Integration.t.sol +++ b/test/integration/Integration.t.sol @@ -35,12 +35,8 @@ abstract contract Integration_Test is Base_Test { }); } - function defaultDeposit() internal { - defaultDeposit(defaultStreamId); - } - - function defaultDeposit(uint256 streamId) internal { - openEnded.deposit(streamId, DEPOSIT_AMOUNT); + function depositToDefaultStream() internal { + openEnded.deposit(defaultStreamId, DEPOSIT_AMOUNT); } /*////////////////////////////////////////////////////////////////////////// diff --git a/test/integration/depletion-time-of/depletionTimeOf.t.sol b/test/integration/depletion-time-of/depletionTimeOf.t.sol new file mode 100644 index 00000000..8967ea22 --- /dev/null +++ b/test/integration/depletion-time-of/depletionTimeOf.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.22; + +import { Integration_Test } from "../Integration.t.sol"; + +contract DepletionTimeOf_Integration_Test is Integration_Test { + function test_RevertGiven_Null() external { + // it should revert + expectRevertNull(); + openEnded.depletionTimeOf(nullStreamId); + } + + function test_RevertGiven_Paused() external givenNotNull { + // it should revert + expectRevertPaused(); + openEnded.depletionTimeOf(defaultStreamId); + } + + function test_WhenBalanceIsZero() external view givenNotNull givenNotPaused { + // it should return 0 + uint40 depletionTime = openEnded.depletionTimeOf(defaultStreamId); + assertEq(depletionTime, 0, "depletion time"); + } + + modifier whenBalanceIsNotZero() { + depositToDefaultStream(); + _; + } + + function test_WhenStreamHasDebt() external givenNotNull givenNotPaused whenBalanceIsNotZero { + vm.warp({ newTimestamp: block.timestamp + SOLVENCY_PERIOD }); + // it should return 0 + uint40 depletionTime = openEnded.depletionTimeOf(defaultStreamId); + assertEq(depletionTime, 0, "depletion time"); + } + + function test_WhenStreamHasNoDebt() external givenNotNull givenNotPaused whenBalanceIsNotZero { + // it should return the time at which the stream depletes its balance + uint40 depletionTime = openEnded.depletionTimeOf(defaultStreamId); + assertEq(depletionTime, block.timestamp + SOLVENCY_PERIOD, "depletion time"); + } +} diff --git a/test/integration/depletion-time-of/depletionTimeOf.tree b/test/integration/depletion-time-of/depletionTimeOf.tree new file mode 100644 index 00000000..29c1366e --- /dev/null +++ b/test/integration/depletion-time-of/depletionTimeOf.tree @@ -0,0 +1,14 @@ +DepletionTimeOf_Integration_Test +├── given null +│ └── it should revert +└── given not null + ├── given paused + │ └── it should revert + └── given not paused + ├── when balance is zero + │ └── it should return 0 + └── when balance is not zero + ├── when stream has debt + │ └── it should return 0 + └── when stream has no debt + └── it should return the time at which the stream depletes its balance diff --git a/test/integration/pause/pause.t.sol b/test/integration/pause/pause.t.sol index e1fc6c42..c1128c87 100644 --- a/test/integration/pause/pause.t.sol +++ b/test/integration/pause/pause.t.sol @@ -90,7 +90,7 @@ contract Pause_Integration_Test is Integration_Test { givenWithdrawableAmountNotZero givenRefundableAmountNotZero { - defaultDeposit(); + depositToDefaultStream(); uint128 withdrawableAmount = openEnded.withdrawableAmountOf(defaultStreamId); diff --git a/test/integration/refund-from-stream/refundFromStream.t.sol b/test/integration/refund-from-stream/refundFromStream.t.sol index 2cc424b3..42c6a942 100644 --- a/test/integration/refund-from-stream/refundFromStream.t.sol +++ b/test/integration/refund-from-stream/refundFromStream.t.sol @@ -12,7 +12,7 @@ contract RefundFromStream_Integration_Test is Integration_Test { function setUp() public override { Integration_Test.setUp(); - defaultDeposit(); + depositToDefaultStream(); vm.warp({ newTimestamp: WARP_ONE_MONTH }); } diff --git a/test/integration/refundable-amount-of/refundableAmountOf.t.sol b/test/integration/refundable-amount-of/refundableAmountOf.t.sol index d4c265f8..97845f10 100644 --- a/test/integration/refundable-amount-of/refundableAmountOf.t.sol +++ b/test/integration/refundable-amount-of/refundableAmountOf.t.sol @@ -19,7 +19,7 @@ contract RefundableAmountOf_Integration_Test is Integration_Test { } function test_RefundableAmountOf_Paused() external givenNotNull { - defaultDeposit(); + depositToDefaultStream(); openEnded.refundableAmountOf(defaultStreamId); vm.warp({ newTimestamp: WARP_ONE_MONTH }); @@ -39,7 +39,7 @@ contract RefundableAmountOf_Integration_Test is Integration_Test { } function test_RefundableAmountOf() external givenNotNull givenNotPaused { - defaultDeposit(); + depositToDefaultStream(); vm.warp({ newTimestamp: WARP_ONE_MONTH }); uint128 refundableAmount = openEnded.refundableAmountOf(defaultStreamId); diff --git a/test/integration/stream-debt-of/streamDebtOf.t.sol b/test/integration/stream-debt-of/streamDebtOf.t.sol index cc3fd833..d8e463b5 100644 --- a/test/integration/stream-debt-of/streamDebtOf.t.sol +++ b/test/integration/stream-debt-of/streamDebtOf.t.sol @@ -14,7 +14,7 @@ contract StreamDebtOf_Integration_Test is Integration_Test { } function test_RevertGiven_BalanceNotLessThanRemainingAmount() external givenNotNull givenPaused { - defaultDeposit(); + depositToDefaultStream(); vm.warp({ newTimestamp: WARP_ONE_MONTH }); openEnded.pause(defaultStreamId); @@ -37,7 +37,7 @@ contract StreamDebtOf_Integration_Test is Integration_Test { } function test_StreamDebtOf_BalanceNotLessThanSum() external givenNotNull givenNotPaused { - defaultDeposit(); + depositToDefaultStream(); vm.warp({ newTimestamp: WARP_ONE_MONTH }); uint128 actualDebt = openEnded.streamDebtOf(defaultStreamId); diff --git a/test/integration/withdraw-at/withdrawAt.t.sol b/test/integration/withdraw-at/withdrawAt.t.sol index 290d3552..1c044a88 100644 --- a/test/integration/withdraw-at/withdrawAt.t.sol +++ b/test/integration/withdraw-at/withdrawAt.t.sol @@ -12,7 +12,7 @@ contract WithdrawAt_Integration_Test is Integration_Test { function setUp() public override { Integration_Test.setUp(); - defaultDeposit(); + depositToDefaultStream(); vm.warp({ newTimestamp: WARP_ONE_MONTH }); } diff --git a/test/integration/withdraw-max/withdrawMax.t.sol b/test/integration/withdraw-max/withdrawMax.t.sol index 16080138..70818f03 100644 --- a/test/integration/withdraw-max/withdrawMax.t.sol +++ b/test/integration/withdraw-max/withdrawMax.t.sol @@ -9,7 +9,7 @@ contract WithdrawMax_Integration_Concrete_Test is Integration_Test { function setUp() public override { Integration_Test.setUp(); - defaultDeposit(); + depositToDefaultStream(); vm.warp({ newTimestamp: WARP_ONE_MONTH }); } diff --git a/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol b/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol index 948d68c0..00d8d281 100644 --- a/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol +++ b/test/integration/withdrawable-amount-of/withdrawableAmountOf.t.sol @@ -40,7 +40,7 @@ contract WithdrawableAmountOf_Integration_Test is Integration_Test { function test_WithdrawableAmountOf_StreamPaused() external givenNotNull givenBalanceNotZero givenPaused { // Deposit enough funds. - defaultDeposit(); + depositToDefaultStream(); // Simulate passage of time. vm.warp({ newTimestamp: WARP_ONE_MONTH }); @@ -91,7 +91,7 @@ contract WithdrawableAmountOf_Integration_Test is Integration_Test { // Simulate passage of time. vm.warp({ newTimestamp: WARP_ONE_MONTH }); - defaultDeposit(); + depositToDefaultStream(); uint128 newRatePerSecond = RATE_PER_SECOND * 2; diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol index 2b94f485..73fdc11b 100644 --- a/test/utils/Constants.sol +++ b/test/utils/Constants.sol @@ -11,6 +11,7 @@ abstract contract Constants { uint128 public constant ONE_MONTH_REFUNDABLE_AMOUNT = DEPOSIT_AMOUNT - ONE_MONTH_STREAMED_AMOUNT; uint128 public constant RATE_PER_SECOND = 0.001e18; // 86.4 daily uint128 public constant REFUND_AMOUNT = 10_000e18; + uint128 public constant SOLVENCY_PERIOD = DEPOSIT_AMOUNT / RATE_PER_SECOND; uint40 public immutable WARP_ONE_MONTH = MAY_1_2024 + ONE_MONTH; uint128 public constant WITHDRAW_AMOUNT = 2500e18; uint40 public immutable WITHDRAW_TIME = MAY_1_2024 + 2_500_000;