Flow

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

A voided Flow should not have any debt based on the invariant ud=0, but this is not correctly handled hence debt will still persist

Summary

The smart contract contains a vulnerability in the debt handling mechanism when a flow is voided. Specifically, the debt is not accurately written off due to a precision error, particularly for tokens with fewer decimals (such as WBTC, USDC, and Gemini USD). This issue breaks an intended invariant: voiding a flow should result in zero debt. Due to the scaling mismatch, the current implementation leaves residual debt, meaning the invariant is not fully enforced, which shows inaccurate fund management.

Vulnerability Details

1. **Debt Persistence on Voiding Flow**:

- The `_void` function should set all debt to zero when a flow is voided. However, a precision error arises due to the difference in decimal places across tokens, particularly for those with fewer than 18 decimal places.

- When tokens with fewer decimal places are scaled up, small residual debt values persist even when debt should be zeroed out, causing an unintended debt balance.

/// @dev See the documentation for the user-facing functions that call this internal function.
function _void(uint256 streamId) internal {
// Check: `msg.sender` is either the stream's sender, recipient or an approved third party.
if (msg.sender != _streams[streamId].sender && !_isCallerStreamRecipientOrApproved(streamId)) {
revert Errors.SablierFlow_Unauthorized({ streamId: streamId, caller: msg.sender });
}
@audit<1.Rounding issue affects value>>> uint256 debtToWriteOff = _uncoveredDebtOf(streamId);
// If the stream is solvent, update the total debt normally.
@audit<2.>>> if (debtToWriteOff == 0) {
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
if (ongoingDebtScaled > 0) {
// Effect: Update the snapshot debt by adding the ongoing debt.
_streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
}
}
// If the stream is insolvent, write off the uncovered debt.
@audit<3.>>> else {
// Effect: update the total debt by setting snapshot debt to the stream balance.
_streams[streamId].snapshotDebtScaled =
Helpers.scaleAmount({ amount: _streams[streamId].balance, decimals: _streams[streamId].tokenDecimals });
}
// Effect: update the snapshot time.
_streams[streamId].snapshotTime = uint40(block.timestamp);
// Effect: set the rate per second to zero.
_streams[streamId].ratePerSecond = ud21x18(0);
// Effect: set the stream as voided.
_streams[streamId].isVoided = true;
// Log the void.
emit ISablierFlow.VoidFlowStream({
streamId: streamId,
sender: _streams[streamId].sender,
recipient: _ownerOf(streamId),
caller: msg.sender,
newTotalDebt: _totalDebtOf(streamId),
writtenOffDebt: debtToWriteOff
});
}

2. **Precision Error and Scaling Mismatch**:

- When tokens with fewer decimal places (e.g., USDC at 6 decimals) are scaled to 18 decimals, residual debt can remain due to rounding and precision discrepancies.

- The current function compares unscaled debt against a non-scaled balance, which results in incorrect calculations when writing off debt, leaving a small but non-zero debt balance.

/// @dev The total debt is the sum of the snapshot debt and the ongoing debt descaled to token's decimal. This
/// value is independent of the stream's balance.
function _totalDebtOf(uint256 streamId) internal view returns (uint256) {
uint256 totalDebtScaled = _ongoingDebtScaledOf(streamId) + _streams[streamId].snapshotDebtScaled;
@audit<1.>>> return Helpers.descaleAmount({ amount: totalDebtScaled, decimals: _streams[streamId].tokenDecimals });
}
/// @dev Calculates the uncovered debt.
function _uncoveredDebtOf(uint256 streamId) internal view returns (uint256) {
uint128 balance = _streams[streamId].balance;
@audit<2.>>> uint256 totalDebt = _totalDebtOf(streamId);
if (balance < totalDebt) {
return totalDebt - balance;
} else {
@audit<3.rounding issue but ok>>> return 0;
}
}

3. **Broken Invariant**:

- The smart contract’s invariant specifies that on voiding a flow, the total debt should equal zero. Due to the precision error, this invariant is not maintained, as scaled debt may not perfectly match the balance.

Impact

Although this vulnerability has no direct financial impact, it has a medium severity level due to the broken invariant and potential accounting inconsistencies. The inability to fully clear debt could lead to:

- Inaccurate recordkeeping within the contract regarding debt status.

Tools Used

Manual Review

Recommendations

1. **Align Balance and Debt Scaling**:

- When comparing debt and balance values, ensure they are on the same scale. Adjust the `_void` function to scale the balance before comparison to eliminate residual debt and enforce the invariant correctly.

-- uint256 debtToWriteOff = _uncoveredDebtOf(streamId);
++ uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
++ if (ongoingDebtScaled > 0) {
// Effect: Update the snapshot debt by adding the ongoing debt.
++ _streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
++ }
++ uint256 scaledbalance = Helpers.scaleAmount({ amount: _streams[streamId].balance, decimals: _streams[streamId].tokenDecimals });
++ if ( _streams[streamId].snapshotDebtScaled > scaledbalance){
++ uint256 debtToWriteOff = _streams[streamId].snapshotDebtScaled - scaledbalance
++ }
// If the stream is solvent, update the total debt normally.
if (debtToWriteOff == 0) {
-- uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
-- if (ongoingDebtScaled > 0) {
-- // Effect: Update the snapshot debt by adding the ongoing debt.
-- _streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
-- }
}
// If the stream is insolvent, write off the uncovered debt.
else {
// Effect: update the total debt by setting snapshot debt to the stream balance.
-- _streams[streamId].snapshotDebtScaled =
-- Helpers.scaleAmount({ amount: _streams[streamId].balance, decimals: _streams[streamId].tokenDecimals });
++ _streams[streamId].snapshotDebtScaled = scaledbalance;
}
Updates

Lead Judging Commences

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

Appeal created

bigsam Submitter
9 months ago
inallhonesty Lead Judge
9 months ago
inallhonesty Lead Judge 9 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.