Flow

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

Depletion Oracle Returns Zero for Both Depleted and Actively-Depleting Streams Leading to Ambiguous Stream Status

Summary

The depletionTimeOf function in SablierFlow serves as a critical timing oracle, calculating when a token stream will become insolvent. Its early exit condition uses a >= comparison with balanceScaled + oneMVTScaled to determine stream depletion, returning 0 for both already-depleted streams and streams exactly at their depletion point. This comparison exists as a pre-check before performing more complex depletion time calculations, but its handling of edge cases impacts the protocol's ability to maintain precise stream state information.

The issue centers around this comparison:

https://github.com/Cyfrin/2024-10-sablier/blob/main/src/SablierFlow.sol#L57

function depletionTimeOf(uint256 streamId) ... {
// ... initial setup ...
uint256 balanceScaled = Helpers.scaleAmount({ amount: balance, decimals: tokenDecimals });
uint256 snapshotDebtScaled = _streams[streamId].snapshotDebtScaled;
uint256 oneMVTScaled = Helpers.scaleAmount({ amount: 1, decimals: tokenDecimals });
// The critical comparison
if (snapshotDebtScaled + _ongoingDebtScaledOf(streamId) >= balanceScaled + oneMVTScaled) {
return 0;
}
}

The problem lies in the >= comparison and returning 0. This creates ambiguity between two distinct states:

  1. Stream that is already depleted:

// Example scenario 1
balanceScaled = 1000
snapshotDebtScaled = 900
ongoingDebtScaled = 200
// 900 + 200 >= 1000 + 1
// 1100 >= 1001 -> returns 0
  1. Stream that is exactly at its depletion point:

// Example scenario 2
balanceScaled = 1000
snapshotDebtScaled = 999
ongoingDebtScaled = 2
// 999 + 2 >= 1000 + 1
// 1001 >= 1001 -> returns 0

This becomes problematic when integrating systems need to differentiate between these states.

The situation gets more complex when you consider how it interacts with other contract functions:

// In the same contract
function _ongoingDebtScaledOf(uint256 streamId) internal view returns (uint256) {
uint256 blockTimestamp = block.timestamp;
uint256 snapshotTime = _streams[streamId].snapshotTime;
uint256 ratePerSecond = _streams[streamId].ratePerSecond.unwrap();
if (ratePerSecond == 0 || blockTimestamp <= snapshotTime) {
return 0;
}
unchecked {
uint256 elapsedTime = blockTimestamp - snapshotTime;
return elapsedTime * ratePerSecond;
}
}

The edge case becomes particularly tricky because:

  1. The snapshot debt and ongoing debt calculations use different scales

  2. The exact depletion point comparison includes the MVT (Minimum Value Transferable)

  3. Block timestamp variations can affect the ongoing debt calculation

Impact

The early exit condition's inability to distinguish between depleted and exactly-depleting streams creates a critical ambiguity in the protocol's stream state management. When depletionTimeOf returns 0 for both conditions, it forces downstream contracts and integrations to operate with incomplete information about stream status. This state confusion compromises the precision of automatic settlement systems and can lead to missed final settlements or incorrect stream lifecycle handling, particularly in time-sensitive DeFi operations where exact depletion timing affects financial outcomes. The collapse of distinct states into a single return value effectively breaks the stream status determinism that integrated protocols rely on for accurate financial operations.

Fix

A better implementation might look like:

function depletionTimeOf(uint256 streamId)
external
view
returns (uint256 depletionTime, StreamState state)
{
uint256 totalDebtScaled = snapshotDebtScaled + _ongoingDebtScaledOf(streamId);
uint256 totalAvailableScaled = balanceScaled + oneMVTScaled;
if (totalDebtScaled > totalAvailableScaled) {
return (0, StreamState.DEPLETED);
} else if (totalDebtScaled == totalAvailableScaled) {
return (block.timestamp, StreamState.DEPLETING_NOW);
}
// Continue with regular depletion calculation...
// ...
}

This would solve the ambiguity by:

  1. Clearly differentiating between states

  2. Providing more information to calling contracts

  3. Making the exact depletion point detectable

  4. Maintaining backward compatibility through careful state handling

Updates

Lead Judging Commences

inallhonesty Lead Judge 10 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

0xstalin Auditor
10 months ago
0xgenaudits Auditor
10 months ago
ljj Auditor
10 months ago
cheatc0d33 Submitter
10 months ago
cheatc0d33 Submitter
10 months ago
0xstalin Auditor
10 months ago
cheatc0d33 Submitter
10 months ago
inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 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.