Summary
_calculateStreamedAmount
is the most important function of any stream, because it's used to calculate the amount of tokens that have been streamed, up to block.timestamp
.
function _calculateStreamedAmount(uint256 streamId) internal view override returns (uint128) {
uint256 cliffTime = uint256(_cliffs[streamId]);
uint256 blockTimestamp = block.timestamp;
if (cliffTime > blockTimestamp) {
return 0;
}
uint256 endTime = uint256(_streams[streamId].endTime);
if (blockTimestamp >= endTime) {
return _streams[streamId].amounts.deposited;
}
unchecked {
uint256 startTime = uint256(_streams[streamId].startTime);
UD60x18 elapsedTime = ud(blockTimestamp - startTime);
UD60x18 totalDuration = ud(endTime - startTime);
UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration);
UD60x18 depositedAmount = ud(_streams[streamId].amounts.deposited);
UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount);
if (streamedAmount.gt(depositedAmount)) {
return _streams[streamId].amounts.withdrawn;
}
return uint128(streamedAmount.intoUint256());
}
}
In the case for SablierV2LockupLinear
, the function looks like so.
One key point, is that the protocol allows creation of streams in the future, i.e, startTime
is somewhere in the future.
You can see that such streams, are PENDING
when _statusOf
is called for them.
function _statusOf(uint256 streamId) internal view returns (Lockup.Status) {
if (_streams[streamId].isDepleted) {
return Lockup.Status.DEPLETED;
} else if (_streams[streamId].wasCanceled) {
return Lockup.Status.CANCELED;
}
if (block.timestamp < _streams[streamId].startTime) {
return Lockup.Status.PENDING;
}
Vulnerability Details
The issue lies in the fact, that _calculateStreamedAmount
doesn't account for this case and attempts to calculate the following:
unchecked {
uint256 startTime = uint256(_streams[streamId].startTime);
UD60x18 elapsedTime = ud(blockTimestamp - startTime);
UD60x18 totalDuration = ud(endTime - startTime);
UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration);
UD60x18 depositedAmount = ud(_streams[streamId].amounts.deposited);
UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount);
if (streamedAmount.gt(depositedAmount)) {
return _streams[streamId].amounts.withdrawn;
}
return uint128(streamedAmount.intoUint256());
}
elapsedTime
will attempt to ud
(wrap), blockTimestamp - startTime
, which will underflow and wrap around, because we are in an unchecked block.
elapsedTime
becomes massive and is then passed down to the following line.
UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration);
This will revert with the following error:
Note that sometimes, the tx reverted on this line, but it always reverted:
UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount);
Impact
DoS:
Tools Used
Manual Review
Recommendations
Add a similar check to the one for cliffTime
if (_streams[streamId].startTime > block.timestamp) {
return 0;
}