Flow

Sablier
FoundryDeFi
20,000 USDC
View results
Submission Details
Severity: medium
Invalid

Snapshot Boundary Time Check in Sablier Protocol Skips One Second of Debt Calculation Leading to Token Leakage and False Solvency States

Summary

The SablierFlow protocol implements a token streaming mechanism where tokens flow from sender to recipient at a defined rate per second, with each stream represented as an NFT. The system uses snapshots to track debt accumulation and stream status, managing transitions between states like SOLVENT, INSOLVENT, PAUSED, and VOIDED. Critical to the system's accounting is _ongoingDebtScaledOf, which calculates accumulated debt since the last snapshot. The vulnerability surfaces in the boundary condition handling of this debt calculation, specifically when block.timestamp exactly matches a stream's snapshot time, affecting the protocol's core financial accounting and stream state management.

The issue centers around how debt is calculated at exact snapshot times. Let's look at the critical code paths:

First, the debt calculation starts in _ongoingDebtScaledOf:

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

function _ongoingDebtScaledOf(uint256 streamId) internal view returns (uint256) {
uint256 blockTimestamp = block.timestamp;
uint256 snapshotTime = _streams[streamId].snapshotTime;
uint256 ratePerSecond = _streams[streamId].ratePerSecond.unwrap();
// ISSUE: At blockTimestamp == snapshotTime, returns 0
if (ratePerSecond == 0 || blockTimestamp <= snapshotTime) {
return 0;
}
uint256 elapsedTime = blockTimestamp - snapshotTime;
return elapsedTime * ratePerSecond;
}

The vulnerability manifests when:

blockTimestamp == snapshotTime

In this case, the function returns 0, meaning NO debt is counted for that second, even though the stream is active.

This affects total debt calculation:

function _totalDebtOf(uint256 streamId) internal view returns (uint256) {
// ongoingDebtScaled will be 0 at snapshot time
uint256 totalDebtScaled = _ongoingDebtScaledOf(streamId) + _streams[streamId].snapshotDebtScaled;
return Helpers.descaleAmount({ amount: totalDebtScaled, decimals: _streams[streamId].tokenDecimals });
}

Which then affects status determination:

function statusOf(uint256 streamId) external view returns (Flow.Status status) {
if (_streams[streamId].isVoided) {
return Flow.Status.VOIDED;
}
// Will use incorrect debt calculation
bool hasDebt = _uncoveredDebtOf(streamId) > 0;
if (_streams[streamId].ratePerSecond.unwrap() == 0) {
return hasDebt ? Flow.Status.PAUSED_INSOLVENT : Flow.Status.PAUSED_SOLVENT;
}
return hasDebt ? Flow.Status.STREAMING_INSOLVENT : Flow.Status.STREAMING_SOLVENT;
}

Scenario

Let's see a concrete example:

// Example scenario:
// Stream with 100 tokens balance
// Rate: 1 token per second
// At time T:
// - Balance: 100 tokens
// - Previous debt: 99 tokens
// - Snapshot time: T
// - Current time: T (exact match)
// What should happen:
// Current debt = Previous debt (99) + Current second (1) = 100 tokens
// Status should be STREAMING_INSOLVENT
// What actually happens:
// Current debt = Previous debt (99) + Current second (0) = 99 tokens
// Status shows as STREAMING_SOLVENT
// This leads to:
function _withdraw(uint256 streamId, address to, uint128 amount) internal {
// ...
uint256 totalDebtScaled = _ongoingDebtScaledOf(streamId) + _streams[streamId].snapshotDebtScaled;
// totalDebtScaled is missing one second of debt
// Withdrawable amount calculation uses incorrect debt
uint128 withdrawableAmount;
if (balance < totalDebt) {
withdrawableAmount = balance;
} else {
withdrawableAmount = totalDebt.toUint128();
}
// ...
}

The snapshot boundary issue compounds because:

  1. Every stream operation that updates the snapshot time creates a new boundary point:

function _adjustRatePerSecond(uint256 streamId, UD21x18 newRatePerSecond) internal {
// ...
// Creates new snapshot boundary
_streams[streamId].snapshotTime = uint40(block.timestamp);
// ...
}
  1. These operations include:

  • Creating stream

  • Adjusting rate

  • Pausing/restarting

  • Some withdrawals

Impact

The snapshot boundary vulnerability creates a systematic accounting error in the SablierFlow system. When a stream operation aligns with its snapshot time, the protocol fails to account for one second of streaming debt, effectively creating a "free second" of token flow.

This accounting gap propagates through the contract's core mechanisms, causing the protocol to underreport total debt and incorrectly classify stream solvency states. While a single second of missing debt might appear minimal, it manifests at every snapshot boundary and compounds across multiple streams and operations.

Most critically, it allows recipients to withdraw more tokens than they should be entitled to, directly impacting the protocol's accounting integrity and creating a cumulative token leak. For high-value streams or automated systems making frequent use of stream operations that create new snapshots, this vulnerability provides a reliable attack vector for extracting excess value from affected streams.

Fix

Change the timestamp comparison in _ongoingDebtScaledOf:

function _ongoingDebtScaledOf(uint256 streamId) internal view returns (uint256) {
uint256 blockTimestamp = block.timestamp;
uint256 snapshotTime = _streams[streamId].snapshotTime;
uint256 ratePerSecond = _streams[streamId].ratePerSecond.unwrap();
// FIX: Changed <= to <
if (ratePerSecond == 0 || blockTimestamp < snapshotTime) {
return 0;
}
// Include the current second in elapsed time
uint256 elapsedTime = blockTimestamp - snapshotTime + 1;
return elapsedTime * ratePerSecond;
}

Alternative Fix - Add Explicit Current Second Handling:

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) return 0;
if (blockTimestamp < snapshotTime) return 0;
// Handle exact boundary explicitly
if (blockTimestamp == snapshotTime) {
return ratePerSecond; // One second of debt
}
uint256 elapsedTime = blockTimestamp - snapshotTime;
return elapsedTime * ratePerSecond;
}

This ensures:

  1. The current second is always counted in debt calculations

  2. No "free" seconds at snapshot boundaries

  3. Accurate status reporting

  4. Proper withdrawal amounts

The vulnerability is subtle but serious because:

  1. It occurs at every snapshot boundary

  2. Affects fundamental stream accounting

  3. Can be exploited for financial gain

  4. Compounds over multiple operations

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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