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.
function _void(uint256 streamId) internal {
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);
@audit<2.>>> if (debtToWriteOff == 0) {
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
if (ongoingDebtScaled > 0) {
_streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
}
}
@audit<3.>>> else {
_streams[streamId].snapshotDebtScaled =
Helpers.scaleAmount({ amount: _streams[streamId].balance, decimals: _streams[streamId].tokenDecimals });
}
_streams[streamId].snapshotTime = uint40(block.timestamp);
_streams[streamId].ratePerSecond = ud21x18(0);
_streams[streamId].isVoided = true;
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.
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 });
}
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) {
++ _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 (debtToWriteOff == 0) {
-- uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
-- if (ongoingDebtScaled > 0) {
--
-- _streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
-- }
}
else {
-- _streams[streamId].snapshotDebtScaled =
-- Helpers.scaleAmount({ amount: _streams[streamId].balance, decimals: _streams[streamId].tokenDecimals });
++ _streams[streamId].snapshotDebtScaled = scaledbalance;
}