Flow

Sablier
FoundryDeFi
20,000 USDC
View results
Submission Details
Severity: low
Valid

depletionTimeOf() can return 0 and a timestamp value when balance and debt relation didn't change

Summary

The issue is somewhat similar to the issue reported in Cantina's audit report section 3.3.4* (copied below). It describes a problem that depletionTimeOf() returns 0 when it should still return the timestamp of when the totalDebt will exceed the balance by one token. But in this case I want to highlight the return difference of when balance is and is NOT 0.

Vulnerability Details

Inconsistent returns from depletionTimeOf(), depending if balance is 0 or not, even though the time and rate doesn't change. This is because the function first checks if there is no balance and returns 0. But if stream has balance > 0, then it will only return 0 if the debt is above balance + 1 MVT (Minimum Value Transferable).

This creates a scenario where if the value is withdrawn at the time when stream balance = totalDebt the depletionTimeOf() will shift from returning a timestamp slightly into the future to returning 0. This means that the depletion isn't correctly configured of what it considers the "depletion time" if it is the timestamp of when the total debt EXCEEDS the balance or when total debt is EQUAL to the balance.

An example of the problem is that the depletionTimeOf() returns:

  • rps > 0; balance = 1; totalDebt = 1 -> output is a timestamp

  • rps > 0; balance = 0; totalDebt = 0 -> output is 0

Even though in both cases the values are equal, one will provide a timestamp and will consider the stream not yet depleted, while in the other case it is considered depleted.

For a POC example using the project test case please look at "Tools Used" section.

Impact

Not completely accurate depletionTimeOf() value. Returns 0 if balance is 0, but returns timestamp if balance = totalDebt (but their return values should match).

Tools Used

Manual review + foundry tests

The test does this:

  • Matches the totalDebt to stream balance using timestamp warp (prints them out for proof)

  • Prints out the timestamp of now and the expected depletionTime which differ by 1 second

  • Performs a withdrawal to recipient with the amount matching the balance. Post withdraw balance = 0.

  • Prints out the timetstamps again, we see that "now" is still the same, but the depletion time is now 0. Even though the block timestamp hasn't changed and any rate parameters haven't changed either.

function test_DepletionTimeOf_balance_discrepancy() external givenNotNull givenNotPaused givenBalanceNotZero {
console.log("Rate per second : ", flow.getRatePerSecond(defaultStreamId).intoUint128());
vm.warp({ newTimestamp: block.timestamp + 47_408_000 });
console.log("total debt (6 decimals): ", flow.totalDebtOf(defaultStreamId));
console.log("balance (6 decimals) : ", flow.getBalance(defaultStreamId));
console.logString("---PRE withdraw---");
console.log("---now : ", block.timestamp);
console.log("---depletion time : ", flow.depletionTimeOf(defaultStreamId));
vm.stopPrank();
vm.prank(users.recipient);
console.logString("---Withdrawing amount = stream balance---");
flow.withdraw(defaultStreamId, users.recipient, flow.getBalance(defaultStreamId));
console.logString("---POST withdraw---");
console.log("---now : ", block.timestamp);
console.log("---depletion time : ", flow.depletionTimeOf(defaultStreamId));
}

The script will print out:

Logs:
Rate per second : 1000000000000000
total debt (6 decimals): 50000000000
balance (6 decimals) : 50000000000
---PRE withdraw---
---now : 1777740800
---depletion time : 1777740801
---Withdrawing amount = stream balance---
---POST withdraw---
---now : 1777740800
---depletion time : 0

This discrepancy is very minor edge case, that the function can return two different outputs at the same time without changing the rates. And that the depletion time doesn't have to be reached for it to be "depleted". Which is a slight conflict in the depletionTimeOf logic.

Recommendations

Probably the simplest solution is to simply remove the if(balance == 0) case and always follow the same logic path of totalDebt has to EXCEED the balance. Otherwise alter the further down logic checks to switch to checking when the totalDebt EQUALS the balance.

EXTRA NOTES

The @notice comment in ISablierFlow.sol inaccurately describes the depletionTimeOf()

/// @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.

It should probably say that ...If the total debt is MORE than...

Cantina 3.3.4 report (for reference):

3.3.4 depletionTimeOf() returns 0 when still solvent at edge of depletion time

Severity: Low Risk

Context: SablierFlow.sol#L75

Description: The Natspec of the depletionTimeOf() states that it returns 0 when there is uncovered debt.

/// @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.;

The solvencyPeriod is also calculated based on when the debt exceeds the balance by 1.

if (tokenDecimals == 18) {
solvencyAmount = (balance - snapshotDebt + 1);
} else {
uint128 scaleFactor = (10 ** (18 - tokenDecimals)).toUint128();
solvencyAmount = (balance - snapshotDebt + 1) * scaleFactor;
}
uint256 solvencyPeriod = solvencyAmount / _streams[streamId].ratePerSecond.unwrap();

Therfore depletionTimeOf() should not return 0 when the totalDebt == balance, but rather the timestamp at which 1 more token will be streamed.

Recommendation: Change the depleteiontimeOf() as follows:

- if (snapshotDebt + _ongoingDebtOf(streamId) >= balance) {
+ if (snapshotDebt + _ongoingDebtOf(streamId) > balance) {
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

depletionTimeOf() can return 0 and a timestamp value when balance and debt relation didn't change

Appeal created

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

depletionTimeOf() can return 0 and a timestamp value when balance and debt relation didn't change

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.