Flow

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

Protocol Fee Impact Not Considered in Sablier's Solvency Checks Leading to False Stream Status Reporting and Withdrawal Failures

Summary

The SablierFlow protocol applies protocol fees during withdrawals while managing token streams, with fees configured per token type. The protocol's status system determines stream health through solvency checks, comparing stream balances against accumulated debt. Critical to the system's operation is the relationship between statusOf, which determines stream state, and the withdrawal mechanism that applies protocol fees. The core issue emerges from the disconnection between these two systems: status calculations work with raw balances and debt, while actual withdrawals incur additional protocol fees, creating a fundamental disparity in solvency assessment versus realized token availability.

The issue centers around how fees are handled in debt and status calculations. Here's the key problem:

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

// Status calculation doesn't consider protocol fees
function statusOf(uint256 streamId) external view returns (Flow.Status status) {
// ...
bool hasDebt = _uncoveredDebtOf(streamId) > 0; // Raw debt check without fee consideration
// ...
}
// But withdrawals do apply protocol fees
function _withdraw(uint256 streamId, address to, uint128 amount) internal returns (uint128 withdrawnAmount, uint128 protocolFeeAmount) {
// ... withdrawal logic ...
// Protocol fee applied AFTER debt/solvency checks
IERC20 token = _streams[streamId].token;
UD60x18 protocolFee = protocolFee[token];
if (protocolFee > ZERO) {
// Fee reduces actual withdrawal amount
(protocolFeeAmount, amount) = Helpers.calculateAmountsFromFee({ totalAmount: amount, fee: protocolFee });
unchecked {
protocolRevenue[token] += protocolFeeAmount;
}
}
// ...
}

This creates a discrepancy: a stream might appear SOLVENT based on raw debt calculations, but becomes effectively INSOLVENT after protocol fees are applied. For example:

  • Stream balance: 100 tokens

  • Total debt: 98 tokens

  • Protocol fee: 3%

  • Status shows: SOLVENT (98 < 100)

  • Actual withdrawal: 98 * 0.97 = 95.06 tokens (insufficient to cover debt)

The status calculation needs to account for the maximum potential fee impact on debt coverage. This affects withdrawal calculations, solvency checks, and user fund accessibility.

Impact

The misalignment between protocol fee handling and status determination creates a deceptive state representation that undermines the protocol's financial guarantees. When streams report as solvent without accounting for protocol fees, they mask an effective insolvency that only materializes during withdrawal operations. This discrepancy allows streams to operate in an apparently healthy state while being unable to fulfill their intended token flow obligations after fee deduction. The impact ripples through the protocol's accounting system, where streams consistently over-report their effective balance coverage, leading to potential withdrawal failures and compromised stream reliability. This architectural flaw in fee consideration creates a persistent gap between reported and actual stream health, potentially trapping user funds in streams that appear solvent but cannot fulfill their streaming obligations due to unconsidered fee impacts.

Fix

// Add getter for effective balance after maximum fee
function _getEffectiveBalanceWithFees(uint256 streamId) internal view returns (uint256) {
uint128 balance = _streams[streamId].balance;
UD60x18 fee = protocolFee[_streams[streamId].token];
if (fee == ZERO) {
return balance;
}
// Calculate minimum amount after maximum fee
return balance - Helpers.calculateAmountsFromFee({
totalAmount: balance,
fee: fee
}).feeAmount;
}
// Modify uncovered debt calculation to use fee-adjusted balance
function _uncoveredDebtOf(uint256 streamId) internal view returns (uint256) {
uint256 effectiveBalance = _getEffectiveBalanceWithFees(streamId);
uint256 totalDebt = _totalDebtOf(streamId);
if (effectiveBalance < totalDebt) {
return totalDebt - effectiveBalance;
} else {
return 0;
}
}
// Status now correctly reflects post-fee solvency
function statusOf(uint256 streamId) external view returns (Flow.Status status) {
if (_streams[streamId].isVoided) {
return Flow.Status.VOIDED;
}
// Now considers fees in 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;
}

This fix ensures status calculations account for maximum potential fee impact, aligning stream state representation with actual token availability during withdrawals.

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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