Flow

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

Inconsistent Rounding in Sablier's Token Stream Calculations Leads to Accumulated Precision Loss and Potential Solvency Miscalculations

Summary

SablierFlow performs continuous token streaming calculations requiring multiple conversions between token decimals and fixed-point math (18 decimals). These conversions happen during debt calculations (_totalDebtOf), ongoing debt tracking (_ongoingDebtScaledOf), and withdrawal operations, each involving scaling and descaling operations. The protocol's core accounting relies on precise debt to balance comparisons for solvency checks, making the rounding behavior in these numerical operations critical for maintaining accurate stream states and token flow accounting.

The issue revolves around inconsistent and potentially unsafe rounding in debt calculations. Here's where it manifests:

function _totalDebtOf(uint256 streamId) internal view returns (uint256) {
// First scaling operation
uint256 totalDebtScaled = _ongoingDebtScaledOf(streamId) + _streams[streamId].snapshotDebtScaled;
// Potentially unsafe descaling - rounds down
return Helpers.descaleAmount({ amount: totalDebtScaled, decimals: _streams[streamId].tokenDecimals });
}
function _ongoingDebtScaledOf(uint256 streamId) internal view returns (uint256) {
// ... time checks ...
uint256 elapsedTime = blockTimestamp - snapshotTime;
// Multiplication can cause precision loss
return elapsedTime * ratePerSecond;
}
// Used in status determination
function _uncoveredDebtOf(uint256 streamId) internal view returns (uint256) {
uint128 balance = _streams[streamId].balance;
uint256 totalDebt = _totalDebtOf(streamId); // Already rounded down
// Comparison with potentially rounded values
if (balance < totalDebt) {
return totalDebt - balance;
} else {
return 0;
}
}
// In withdrawal logic
function _withdraw(uint256 streamId, address to, uint128 amount) internal {
// ...
// Another scaling operation
uint256 amountScaled = Helpers.scaleAmount(amount, tokenDecimals);
if (amountScaled <= _streams[streamId].snapshotDebtScaled) {
_streams[streamId].snapshotDebtScaled -= amountScaled;
} else {
// Complex debt calculation with multiple rounding points
_streams[streamId].snapshotDebtScaled = totalDebtScaled - amountScaled;
}
// ...
}

The protocol performs multiple scaling and descaling operations, each potentially rounding in different directions. For debt calculations, rounding down could understate debt, while for balance comparisons, these rounded values affect solvency determinations. This becomes particularly problematic when dealing with tokens of different decimals or when amounts approach rounding boundaries, potentially allowing streams to appear solvent when they should be insolvent due to accumulated rounding effects.

Impact

The inconsistent rounding behavior across SablierFlow's debt calculations creates subtle arithmetic discrepancies that compound through the protocol's accounting system. Each scaling operation between token decimals and fixed-point math introduces potential precision loss, with debt calculations typically rounding down while comparisons use these imprecise values for critical solvency determinations.

This asymmetric rounding propagates through stream operations, gradually eroding the protocol's accounting accuracy and potentially allowing streams to operate in technically insolvent states. The cumulative effect of these rounding inconsistencies becomes particularly acute with high-precision tokens or high-frequency operations, where the accumulated error can materialize as real financial discrepancies between expected and actual token flows, undermining the protocol's core promise of precise token streaming.

Fix

// Add conservative rounding utility
library ConservativeRounding {
function scaleAmountUp(uint256 amount, uint8 decimals) internal pure returns (uint256) {
if (amount == 0) return 0;
uint256 scaled = amount * (10 ** 18);
uint256 divisor = 10 ** decimals;
return (scaled + divisor - 1) / divisor;
}
function descaleAmountUp(uint256 scaledAmount, uint8 decimals) internal pure returns (uint256) {
if (scaledAmount == 0) return 0;
uint256 divisor = 10 ** (18 - decimals);
return (scaledAmount + divisor - 1) / divisor;
}
}
// Modify debt calculations to use conservative rounding
function _totalDebtOf(uint256 streamId) internal view returns (uint256) {
uint256 totalDebtScaled = _ongoingDebtScaledOf(streamId) + _streams[streamId].snapshotDebtScaled;
// Round up for debt calculations
return ConservativeRounding.descaleAmountUp(totalDebtScaled, _streams[streamId].tokenDecimals);
}
function _uncoveredDebtOf(uint256 streamId) internal view returns (uint256) {
uint128 balance = _streams[streamId].balance;
// Get conservative debt estimate
uint256 totalDebt = _totalDebtOf(streamId);
if (balance < totalDebt) {
return totalDebt - balance;
}
// Add minimum margin to handle rounding
return balance - totalDebt < DUST_THRESHOLD ? DUST_THRESHOLD : 0;
}
function _withdraw(uint256 streamId, address to, uint128 amount) internal {
// Scale amount conservatively for debt bookkeeping
uint256 amountScaled = ConservativeRounding.scaleAmountUp(amount, _streams[streamId].tokenDecimals);
// Rest of withdrawal logic using conservative scaled amounts
// ...
}

The fix implements consistent, conservative rounding that always rounds up for debt calculations and maintains a safety margin, ensuring the protocol remains solvent even under worst-case rounding scenarios.

Updates

Lead Judging Commences

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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