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 {
if (newRatePerSecond.unwrap() == _streams[streamId].ratePerSecond.unwrap()) {
revert Errors.SablierFlow_RatePerSecondNotDifferent(streamId, newRatePerSecond);
}
@>
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
if (ongoingDebtScaled > 0) {
_streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
}
_streams[streamId].snapshotTime = uint40(block.timestamp);
_streams[streamId].ratePerSecond = newRatePerSecond;
}
function _pause(uint256 streamId) internal {
_adjustRatePerSecond({ streamId: streamId, newRatePerSecond: ud21x18(0) });
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) {
@> _streams[streamId].snapshotDebtScaled += ongoingDebtScaled;
}
}
...
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
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;
}