Flow

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

Loss of Precision when `WithdrawMax` called

Summary

Due to the scaling and descaling of token amounts in the same call, some tokens may be stuck in the contract.

Vulnerability Details

There is a possibility for users to withdraw the max amount of tokens via withdrawMax. Let's take a look on this function:

function withdrawMax(
uint256 streamId,
address to
)
external
override
noDelegateCall
notNull(streamId)
updateMetadata(streamId)
returns (uint128 withdrawnAmount, uint128 protocolFeeAmount)
{
uint128 coveredDebt = _coveredDebtOf(streamId);
// Checks, Effects, and Interactions: make the withdrawal.
(withdrawnAmount, protocolFeeAmount) = _withdraw(streamId, to, coveredDebt);
}

First of all it calculates a coveredDedt amount in _coveredDebtOf(streamId). And descale the amount:

function _coveredDebtOf(uint256 streamId) internal view returns (uint128) {
...
@> uint256 totalDebt = _totalDebtOf(streamId);
if (balance < totalDebt) {
return balance;
}
return totalDebt.toUint128();
}
function _totalDebtOf(uint256 streamId) internal view returns (uint256) {
uint256 totalDebtScaled = _ongoingDebtScaledOf(streamId) + _streams[streamId].snapshotDebtScaled;
@> return Helpers.descaleAmount({ amount: totalDebtScaled, decimals: _streams[streamId].tokenDecimals });
}

With the next call to _withdraw() descaled value of coveredDebt (as an amount argument) scaled again:

function _withdraw(
uint256 streamId,
address to,
uint128 amount
)
internal
returns (uint128 withdrawnAmount, uint128 protocolFeeAmount)
{
...
// Calculate the amount scaled.
@> uint256 amountScaled = Helpers.scaleAmount(amount, tokenDecimals);
...
return (amount, protocolFeeAmount);
}

Now take a look on scaling functions:

function descaleAmount(uint256 amount, uint8 decimals) internal pure returns (uint256) {
if (decimals == 18) {
return amount;
}
unchecked {
uint256 scaleFactor = 10 ** (18 - decimals);
@> return amount / scaleFactor;
}
}
/// @dev Scales the provided `amount` from token's decimals number to 18 decimals fixed-point number.
function scaleAmount(uint256 amount, uint8 decimals) internal pure returns (uint256) {
if (decimals == 18) {
return amount;
}
unchecked {
uint256 scaleFactor = 10 ** (18 - decimals);
@> return amount * scaleFactor;
}
}

The token is the same, so are the decimals.

So it basically does a division and then a multiplication with the same amount value.

Eventually the presision is lost and some tokens are stuck on the contract.

Event with admin recovery function they will remain on the contract as the prime value of it stored in the aggregateBalance which is deducted on recovery process:

function recover(IERC20 token, address to) external override onlyAdmin {
@> uint256 surplus = token.balanceOf(address(this)) - aggregateBalance[token];
// Check: there is a surplus to recover.
if (surplus == 0) {
revert Errors.SablierFlowBase_SurplusZero(address(token));
}
// Interaction: transfer the surplus to the provided address.
token.safeTransfer(to, surplus);
emit Recover(msg.sender, token, to, surplus);
}

Impact

The presision is lost and some tokens are stuck on the contract.

Tools Used

Manual review

Recommendations

Consider adding a bool argument to the _withdraw() function and refining it to work with scaled amounts to avoid additional scaling.

Updates

Lead Judging Commences

inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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