Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: depletionTimeOf calculation #317

Merged
merged 7 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/SablierFlow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -71,24 +71,35 @@ contract SablierFlow is

uint8 tokenDecimals = _streams[streamId].tokenDecimals;
uint256 balanceScaled = Helpers.scaleAmount({ amount: balance, decimals: tokenDecimals });

uint256 snapshotDebtScaled = _streams[streamId].snapshotDebtScaled;

// If the stream has uncovered debt, return zero.
if (snapshotDebtScaled + _ongoingDebtScaledOf(streamId) > balanceScaled) {
// MVT represents Minimum Value Transferable, the smallest amount of token that can be transferred, which is
// always 1 in token's decimal.
uint256 oneMVTScaled = Helpers.scaleAmount({ amount: 1, decimals: tokenDecimals });

// If the total debt exceeds balance, return zero.
if (snapshotDebtScaled + _ongoingDebtScaledOf(streamId) >= balanceScaled + oneMVTScaled) {
return 0;
}

// Depletion time is defined as the UNIX timestamp beyond which the total debt exceeds stream balance.
// So we calculate it by solving: debt at depletion time = stream balance + 1. This ensures that we find the
// lowest timestamp at which the debt exceeds the balance.
uint256 ratePerSecond = _streams[streamId].ratePerSecond.unwrap();

// Depletion time is defined as the UNIX timestamp at which the total debt exceeds stream balance by 1 unit of
// token (mvt). So we calculate it by solving: total debt at depletion time = stream balance + 1. This ensures
// that we find the lowest timestamp at which the total debt exceeds the stream balance.
// Safe to use unchecked because the calculations cannot overflow or underflow.
unchecked {
uint256 solvencyAmount =
balanceScaled - snapshotDebtScaled + Helpers.scaleAmount({ amount: 1, decimals: tokenDecimals });
uint256 solvencyPeriod = solvencyAmount / _streams[streamId].ratePerSecond.unwrap();
uint256 solvencyAmount = balanceScaled - snapshotDebtScaled + oneMVTScaled;
uint256 solvencyPeriod = solvencyAmount / ratePerSecond;

depletionTime = _streams[streamId].snapshotTime + solvencyPeriod;
// If the division is exact, return the depletion time.
if (solvencyAmount % ratePerSecond == 0) {
depletionTime = _streams[streamId].snapshotTime + solvencyPeriod;
}
// Otherwise, round up before returning since the division by rate per second has round down the result.
else {
depletionTime = _streams[streamId].snapshotTime + solvencyPeriod + 1;
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/interfaces/ISablierFlow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ interface ISablierFlow is
/// @param streamId The stream ID for the query.
function coveredDebtOf(uint256 streamId) external view returns (uint128 coveredDebt);

/// @notice Returns the time at which the stream will deplete its balance and start to accumulate uncovered debt. If
/// there already is uncovered debt, it returns zero.
/// @notice Returns the time at which the total debt exceeds stream balance. If the total debt is less than
/// or equal to stream balance, it returns 0.
/// @dev Reverts if `streamId` references a paused or a null stream.
/// @param streamId The stream ID for the query.
function depletionTimeOf(uint256 streamId) external view returns (uint256 depletionTime);
Expand Down
54 changes: 43 additions & 11 deletions tests/integration/concrete/depletion-time-of/depletionTimeOf.t.sol
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.22;

import { UD21x18 } from "@prb/math/src/UD21x18.sol";

import { Integration_Test } from "../../Integration.t.sol";

contract DepletionTimeOf_Integration_Concrete_Test is Integration_Test {
Expand All @@ -15,21 +17,51 @@ contract DepletionTimeOf_Integration_Concrete_Test is Integration_Test {
}

function test_GivenBalanceZero() external view givenNotNull givenNotPaused {
// It should return 0
uint256 depletionTime = flow.depletionTimeOf(defaultStreamId);
assertEq(depletionTime, 0, "depletion time");
// It should return 0.
uint256 actualDepletionTime = flow.depletionTimeOf(defaultStreamId);
assertEq(actualDepletionTime, 0, "depletion time");
}

function test_GivenUncoveredDebt() external givenNotNull givenNotPaused givenBalanceNotZero {
vm.warp({ newTimestamp: WARP_SOLVENCY_PERIOD + 1 });
// It should return 0
uint256 depletionTime = flow.depletionTimeOf(defaultStreamId);
assertEq(depletionTime, 0, "depletion time");
uint256 depletionTimestamp = WARP_SOLVENCY_PERIOD + 1;
vm.warp({ newTimestamp: depletionTimestamp });

// Check that uncovered debt is greater than 0.
assertGt(flow.uncoveredDebtOf(defaultStreamId), 0);

// It should return 0.
uint256 actualDepletionTime = flow.depletionTimeOf(defaultStreamId);
assertEq(actualDepletionTime, 0, "depletion time");
}

modifier givenNoUncoveredDebt() {
_;
}

function test_WhenExactDivision() external givenNotNull givenNotPaused givenBalanceNotZero givenNoUncoveredDebt {
// Create a stream with a rate per second such that the deposit amount produces no remainder when divided by the
// rate per second.
UD21x18 rps = UD21x18.wrap(2e18);
uint256 streamId = createDefaultStream(rps, usdc);
depositDefaultAmount(streamId);
uint256 solvencyPeriod = DEPOSIT_AMOUNT_18D / rps.unwrap();

// It should return the time at which the total debt exceeds the balance.
uint40 actualDepletionTime = uint40(flow.depletionTimeOf(streamId));
uint40 exptectedDepletionTime = WARP_ONE_MONTH + uint40(solvencyPeriod + 1);
assertEq(actualDepletionTime, exptectedDepletionTime, "depletion time");
}

function test_GivenNoUncoveredDebt() external givenNotNull givenNotPaused givenBalanceNotZero {
// It should return the time at which the stream depletes its balance
uint40 depletionTime = uint40(flow.depletionTimeOf(defaultStreamId));
assertEq(depletionTime, WARP_SOLVENCY_PERIOD, "depletion time");
function test_WhenNotExactDivision()
external
givenNotNull
givenNotPaused
givenBalanceNotZero
givenNoUncoveredDebt
{
// It should return the time at which the total debt exceeds the balance.
uint40 actualDepletionTime = uint40(flow.depletionTimeOf(defaultStreamId));
uint256 expectedDepletionTime = WARP_SOLVENCY_PERIOD + 1;
assertEq(actualDepletionTime, expectedDepletionTime, "depletion time");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ DepletionTimeOf_Integration_Concrete_Test
├── given uncovered debt
│ └── it should return 0
└── given no uncovered debt
└── it should return the time at which the stream depletes its balance
├── when exact division
│ └── it should return the time at which the total debt exceeds the balance
└── when not exact division
└── it should return the time at which the total debt exceeds the balance
69 changes: 35 additions & 34 deletions tests/integration/fuzz/depletionTimeOf.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,51 @@ import { Shared_Integration_Fuzz_Test } from "./Fuzz.t.sol";

contract DepletionTimeOf_Integration_Fuzz_Test is Shared_Integration_Fuzz_Test {
/// @dev Checklist:
/// - It should return 0 if the time has already passed the solvency period.
/// - It should return a non-zero value if the time has not yet passed the solvency period.
/// - It should return a non-zero value if the current time is less than the depletion timestamp.
/// - It should return 0 if the current time is equal to or greater than the depletion timestamp.
///
/// Given enough runs, all of the following scenarios should be fuzzed:
/// - Multiple streams, each with different rate per second and decimals.
/// - Multiple points in time, both pre-depletion and post-depletion.
function testFuzz_DepletionTimeOf(
uint256 streamId,
uint40 timeJump,
uint8 decimals
)
external
givenNotNull
givenPaused
{
function testFuzz_DepletionTimeOf(uint256 streamId, uint8 decimals) external givenNotNull givenPaused {
(streamId, decimals,) = useFuzzedStreamOrCreate(streamId, decimals);

// Calculate the solvency period based on the stream deposit.
uint256 solvencyPeriod =
getScaledAmount(flow.getBalance(streamId) + 1, decimals) / flow.getRatePerSecond(streamId).unwrap();

// Bound the time jump to provide a realistic time frame.
timeJump = boundUint40(timeJump, 0 seconds, 100 weeks);

// Simulate the passage of time.
vm.warp({ newTimestamp: getBlockTimestamp() + timeJump });
uint256 carry =
getScaledAmount(flow.getBalance(streamId) + 1, decimals) % flow.getRatePerSecond(streamId).unwrap();

// Assert that depletion time equals expected value.
uint256 actualDepletionTime = flow.depletionTimeOf(streamId);
if (getBlockTimestamp() >= OCT_1_2024 + solvencyPeriod) {
assertEq(actualDepletionTime, 0, "depletion time");

// Assert that uncovered debt is greater than 0.
assertGt(flow.uncoveredDebtOf(streamId), 0, "uncovered debt post depletion time");
} else {
assertEq(actualDepletionTime, OCT_1_2024 + solvencyPeriod, "depletion time");

// Assert that uncovered debt is zero at depletion time.
vm.warp({ newTimestamp: actualDepletionTime });
assertEq(flow.uncoveredDebtOf(streamId), 0, "uncovered debt before depletion time");

// Assert that uncovered debt is greater than 0 right after depletion time.
vm.warp({ newTimestamp: actualDepletionTime + 1 });
assertGt(flow.uncoveredDebtOf(streamId), 0, "uncovered debt after depletion time");
}
uint256 expectedDepletionTime = carry > 0 ? OCT_1_2024 + solvencyPeriod + 1 : OCT_1_2024 + solvencyPeriod;
assertEq(actualDepletionTime, expectedDepletionTime, "depletion time");

// Warp time to 1 second before the depletion timestamp.
vm.warp({ newTimestamp: actualDepletionTime - 1 });
// Assert that total debt does not exceed the stream balance before depletion time.
assertLe(
flow.totalDebtOf(streamId), flow.getBalance(streamId), "pre-depletion period: total debt exceeds balance"
);
assertLe(flow.depletionTimeOf(streamId), getBlockTimestamp() + 1, "depletion time 1 second in future");

// Warp time to the depletion timestamp.
vm.warp({ newTimestamp: actualDepletionTime });
// Assert that total debt exceeds the stream balance at depletion time.
assertGt(
flow.totalDebtOf(streamId),
flow.getBalance(streamId),
"at depletion time: total debt does not exceed balance"
);
assertEq(flow.depletionTimeOf(streamId), 0, "non-zero depletion time at depletion timestamp");

// Warp time to 1 second after the depletion timestamp.
vm.warp({ newTimestamp: actualDepletionTime + 1 });
// Assert that total debt exceeds the stream balance after depletion time.
assertGt(
flow.totalDebtOf(streamId),
flow.getBalance(streamId),
"post-depletion time: total debt does not exceed balance"
);
assertEq(flow.depletionTimeOf(streamId), 0, "non-zero depletion time after depletion timestamp");
}
}
2 changes: 1 addition & 1 deletion tests/invariant/Flow.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ contract Flow_Invariant_Test is Base_Test {
}

/// @dev For non-voided streams, the expected streamed amount should be greater than or equal to the sum of total
/// debt and withdrawn amount. And, the difference between the two should not exceed 10 wei.
/// debt and withdrawn amount. And, the difference between the two should not exceed 10 mvt.
function invariant_TotalStreamedEqTotalDebtPlusWithdrawn() external view {
uint256 lastStreamId = flowStore.lastStreamId();
for (uint256 i = 0; i < lastStreamId; ++i) {
Expand Down
4 changes: 3 additions & 1 deletion tests/utils/Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ abstract contract Constants {
// Time
uint40 internal constant OCT_1_2024 = 1_727_740_800;
uint40 internal constant ONE_MONTH = 30 days; // "30/360" convention
uint40 internal constant SOLVENCY_PERIOD = uint40(DEPOSIT_AMOUNT_18D / RATE_PER_SECOND_U128); // 578 days
// Solvency period is 49999999.999999 seconds.
uint40 internal constant SOLVENCY_PERIOD = uint40(DEPOSIT_AMOUNT_18D / RATE_PER_SECOND_U128); // ~578 days
uint40 internal constant WARP_ONE_MONTH = OCT_1_2024 + ONE_MONTH;
// The following variable represents the timestamp at which the stream depletes all its balance.
uint40 internal constant WARP_SOLVENCY_PERIOD = OCT_1_2024 + SOLVENCY_PERIOD;
uint40 internal constant WITHDRAW_TIME = OCT_1_2024 + 2_500_000;
}
Loading