Flow

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

Setting Rate Per Second below `oneMVTScaled` leads to an impossible state and loss of precision during `void()` and `pause()`

Description

Creators of streams can choose the Rate Per Second they want. Since this rate is evaluated per second, for high-value tokens, there is a high chance that the rate will use several decimals. For example, 1 WBTC is around $72k, and someone wanting to flow $50 per day should set the rate to 0.000_000_008 WBTC.

Flow allows this, even though WBTC only has 8 decimals, because the rate uses 18 decimals, but the real amount calculated will lack that precision, and due tokens won't be accounted for during the pause or the void. This leads to emitted events not being accurate enough, and remaining debt not being accounted for in the writtenOffDebt during void(). Moreover, it will set an impossible state because the snapshotDebtScaled will never be entirely coverable.

function _adjustRatePerSecond(uint256 streamId, UD21x18 newRatePerSecond) internal {
// Check: the new rate per second is different from the current rate per second.
if (newRatePerSecond.unwrap() == _streams[streamId].ratePerSecond.unwrap()) {
revert Errors.SablierFlow_RatePerSecondNotDifferent(streamId, newRatePerSecond);
}
@> // @audit Nothing prevents the RPS to be lower than the oneMVTScaled.
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
// Update the snapshot debt only if the stream has ongoing debt.
if (ongoingDebtScaled > 0) {
// Effect: update the snapshot debt.
_streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
}
// Effect: update the snapshot time.
_streams[streamId].snapshotTime = uint40(block.timestamp);
// Effect: set the new rate per second.
_streams[streamId].ratePerSecond = newRatePerSecond;
}
function _pause(uint256 streamId) internal {
_adjustRatePerSecond({ streamId: streamId, newRatePerSecond: ud21x18(0) });
// Log the pause.
emit ISablierFlow.PauseFlowStream({
streamId: streamId,
sender: _streams[streamId].sender,
recipient: _ownerOf(streamId),
@> totalDebt: _totalDebtOf(streamId)
});
}
function _void(uint256 streamId) internal {
...
@> uint256 debtToWriteOff = _uncoveredDebtOf(streamId);
if (debtToWriteOff == 0) {
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
if (ongoingDebtScaled > 0) {
// Effect: Update the snapshot debt by adding the ongoing debt.
@> _streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
}
}
...
// Log the void.
emit ISablierFlow.VoidFlowStream({
streamId: streamId,
sender: _streams[streamId].sender,
recipient: _ownerOf(streamId),
caller: msg.sender,
@> newTotalDebt: _totalDebtOf(streamId),
@> writtenOffDebt: debtToWriteOff
});
}
function getSnapshotDebtScaled(uint256 streamId)
external
view
override
notNull(streamId)
returns (uint256 snapshotDebtScaled)
{
@> snapshotDebtScaled = _streams[streamId].snapshotDebtScaled;
}

Risk

Likelyhood: Low

  • Will occur for tokens with a high value and few decimals (like WBTC).

Impact: Low

  • Leads debts to have impossible decimals most part of the time.

  • Scaled debt (real debt) and "printed" debt won't match most part of the time.

  • Getters for scaled amounts will return too many decimals, which has no sense for those tokens, and unscaled amount won't reflect the real amount.

  • pause() won't emit the remaining decimals of the debt.

  • void() won't emit the remaining decimals of the debt in the newTotalDebt or the writtenOffDebt.

  • void() will end a stream setting an impossible amount (state) in the _streams[streamId].snapshotDebtScaled

Recommended Mitigation

The protocol should not allow setting the Rate Per Second below the oneMVTScaled to prevent any precision loss, forcing users to choose more relevant tokens for their usage.

function _adjustRatePerSecond(uint256 streamId, UD21x18 newRatePerSecond) internal {
// Check: the new rate per second is different from the current rate per second.
if (newRatePerSecond.unwrap() == _streams[streamId].ratePerSecond.unwrap()) {
revert Errors.SablierFlow_RatePerSecondNotDifferent(streamId, newRatePerSecond);
}
+ uint256 oneMVTScaled = Helpers.scaleAmount({ amount: 1, decimals: tokenDecimals });
+ if (oneMVTScaled > newRatePerSecond.unwrap()) {
+ revert Errors.SablierFlow_RatePerSecondNotBigEnough(streamId, newRatePerSecond);
+ }
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
// Update the snapshot debt only if the stream has ongoing debt.
if (ongoingDebtScaled > 0) {
// Effect: update the snapshot debt.
_streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
}
// Effect: update the snapshot time.
_streams[streamId].snapshotTime = uint40(block.timestamp);
// Effect: set the new rate per second.
_streams[streamId].ratePerSecond = newRatePerSecond;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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