Sablier

Sablier
DeFiFoundry
53,440 USDC
View results
Submission Details
Severity: low
Valid

Stream can not be canceld if transfer are block due to any issue

Summary

The Sabiler Protocol allows the sender to cancel a stream if it is marked as cancelable. When the sender cancels the stream, any remaining assets are returned to sender of stream. However, if the assets are paused or the sender is blacklisted, the cancellation will fail. Most of the time, the stream operates based on a schedule. So the recipient will still receives the funds

Vulnerability Details

The Sabiler Allows the sender of stream to cancel the stream if it is cancelable. The stream could be canceled if there is some miss agreement between the parties. When sender cancel the Stream the following step execute on smart contract level:

  1. Senders calls the cancel function with the streamId.

  2. The code perform some internal checks to ensure that stream is Ok to cancel.

  3. Calculate the remaining amount of stream. we will discuss it in subsequent details.

  4. Transfer the assets to sender if any. Here The Issue reside.

  5. Hit callback if recipient is smart contract.

3 here we calculate the amount to send back. The amount for each stream type is calculated using _calculateStreamedAmount and _calculateStreamedAmount has different implementation for each stream and the common thing is time all the calculation are performed on time basis from startTime to endTime here I will only explain the SablierV2LockupLinear. following code will help to identify the amount withdraw able at given time:

190: function _calculateStreamedAmount(uint256 streamId) internal view override returns (uint128) {
191: // If the cliff time is in the future, return zero.
192: uint256 cliffTime = uint256(_cliffs[streamId]);
193: uint256 blockTimestamp = block.timestamp;
194: if (cliffTime > blockTimestamp) {
195: return 0;
196: }
197:
198: // If the end time is not in the future, return the deposited amount.
199: uint256 endTime = uint256(_streams[streamId].endTime);
200: if (blockTimestamp >= endTime) {
201: return _streams[streamId].amounts.deposited;
202: }
203:
204: // In all other cases, calculate the amount streamed so far. Normalization to 18 decimals is not needed
205: // because there is no mix of amounts with different decimals.
206: unchecked {
207: // Calculate how much time has passed since the stream started, and the stream's total duration.
208: uint256 startTime = uint256(_streams[streamId].startTime);
209: UD60x18 elapsedTime = ud(blockTimestamp - startTime);
210: UD60x18 totalDuration = ud(endTime - startTime);
211:
212: // Divide the elapsed time by the stream's total duration.
213: UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration);
214:
215: // Cast the deposited amount to UD60x18.
216: UD60x18 depositedAmount = ud(_streams[streamId].amounts.deposited);
217:
218: // Calculate the streamed amount by multiplying the elapsed time percentage by the deposited amount.
219: UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount);
220:
221: // Although the streamed amount should never exceed the deposited amount, this condition is checked
222: // without asserting to avoid locking funds in case of a bug. If this situation occurs, the withdrawn
223: // amount is considered to be the streamed amount, and the stream is effectively frozen.
224: if (streamedAmount.gt(depositedAmount)) {
225: return _streams[streamId].amounts.withdrawn;
226: }
227:
228: // Cast the streamed amount to uint128. This is safe due to the check above.
229: return uint128(streamedAmount.intoUint256());
230: }
231: }
232:

It can be observed from above LN208 to LN229 that We first calculate the elapsed time , then take percentage of it with total duration and finally calculate the streamAmount so far. and return the streamAmount.
Now let have a look on Cancel function code :

557: function _cancel(uint256 streamId) internal {
558: // Calculate the streamed amount.
559: uint128 streamedAmount = _calculateStreamedAmount(streamId);
560:
561: // Retrieve the amounts from storage.
562: Lockup.Amounts memory amounts = _streams[streamId].amounts;
563:
564: // Check: the stream is not settled.
565: if (streamedAmount >= amounts.deposited) {
566: revert Errors.SablierV2Lockup_StreamSettled(streamId);
567: }
568:
569: // Check: the stream is cancelable.
570: if (!_streams[streamId].isCancelable) {
571: revert Errors.SablierV2Lockup_StreamNotCancelable(streamId);
572: }
573:
574: // Calculate the sender's amount.
575: uint128 senderAmount;
576: unchecked {
577: senderAmount = amounts.deposited - streamedAmount;
578: }
579:
580: // Calculate the recipient's amount.
581: uint128 recipientAmount = streamedAmount - amounts.withdrawn;
582:
583: // Effect: mark the stream as canceled.
584: _streams[streamId].wasCanceled = true;
585:
586: // Effect: make the stream not cancelable anymore, because a stream can only be canceled once.
587: _streams[streamId].isCancelable = false;
588:
589: // Effect: if there are no assets left for the recipient to withdraw, mark the stream as depleted.
590: if (recipientAmount == 0) {
591: _streams[streamId].isDepleted = true;
592: }
593:
594: // Effect: set the refunded amount.
595: _streams[streamId].amounts.refunded = senderAmount;
596:
597: // Retrieve the sender and the recipient from storage.
598: address sender = _streams[streamId].sender;
599: address recipient = _ownerOf(streamId);
600:
601: // Retrieve the ERC-20 asset from storage.
602: IERC20 asset = _streams[streamId].asset;
603:
604: // Interaction: refund the sender.
605: asset.safeTransfer({ to: sender, value: senderAmount }); // <------------------ @audit : it can revert
606:
607: // Log the cancellation.
608: emit ISablierV2Lockup.CancelLockupStream(streamId, sender, recipient, asset, senderAmount, recipientAmount);
609:
610: // Emits an ERC-4906 event to trigger an update of the NFT metadata.
611: emit MetadataUpdate({ _tokenId: streamId });
612:
613: // Interaction: if the recipient is a contract, try to invoke the cancel hook on the recipient without
614: // reverting if the hook is not implemented, and without bubbling up any potential revert.
615: if (recipient.code.length > 0) {
616: try ISablierV2Recipient(recipient).onLockupStreamCanceled({
617: streamId: streamId,
618: sender: sender,
619: senderAmount: senderAmount,
620: recipientAmount: recipientAmount
621: }) { } catch { }
622: }
623: }

After calculation of streamAmount we subtract the return streamAmount from amounts.deposited and also check if there is some assets left for recipient.update the state of a stream and refunded amount. At LN605 we send the senderAmount to sender of stream. It is where the cancel function would revert in case of pause/blacklisting etc.

Let's have a look at following case :

  1. Sender Bob creates a Linear stream with recipient Alice.

  2. The stream will start from time 1 and ends at time 8.

  3. There is some miss agreement between the parties, The Bob wants to cancel the stream at time 3.

  4. When Bob submit the cancel transaction The transaction got revert due to any reason i.e transfers are paused/ Bob is blacklisted.

  5. Now at time 6 the underline assets transfers are allowed and now Bob cancel the stream. the stream will only send the amount from time 6 to 8.

  6. Alice will withdraw the amount from time 3 to 6. which in reality the Alice is not eligible to receive.

Impact

The recipient will keep receiving assets if the sender cannot cancel the stream due to transfer issues, even after a missed agreement.

Tools Used

Manual Review

Recommendations

One mitigation for this issue could be following :
Add struct with following key attributes :

struct CancelWithdrawal {
IERC20 asset;
address sender;
uint128 amount;
}

and add mapping of sender to _cancelWithdrawal.

mapping(uint256 id => Lockup.CancelWithdrawal cancelWithdrawal) internal _cancelWithdrawal;

add withdrawal function for cancel withdrawal only allow the sender of stream to withdraw.

function withdrawCancelAmount(uint256 id, address to) external {
Lockup.CancelWithdrawal storage withdrawData = _cancelWithdrawal[id];
if (withdrawData.sender != msg.sender) revert(); // custom error message.
if (withdrawData.amount > 0) {
IERC20 asset = withdrawData.asset;
asset.safeTransfer(to, withdrawData.amount);
withdrawData.amount = 0;
// emit event
}
}

Inside _cancel function add following changes:

try asset.transfer({ to: sender, value: senderAmount }){} catch {// @audit : safeTransfer is internal so can not be used with try catch that's why i will not insist on this solution
Lockup.CancelWithdrawal storage withdrawData = _cancelWithdrawal[streamId];
withdrawData.amount = senderAmount;
withdrawData.sender = sender;
withdrawData.asset = asset;
};

For other solution just Keep in Mind that due to some third party issue make sure the cancel stream will not revert. you can also completely remove the transfer from cancel call only update the states and allows the sender to withdraw funds in separate function.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Push over pull for `_cancel` in context of Pausable tokens

0xnevi Judge
about 1 year ago
0xaman Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Push over pull for `_cancel` in context of Pausable tokens

Support

FAQs

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