Flow

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

Incorrect stream debt accounting leads to user fund loss

Summary

In SablierFlow's withdrawal mechanism causes incorrect debt accounting when users withdraw amounts larger than the snapshot debt. The issue results in users receiving less funds than entitled due to improper handling of ongoing debt calculations when the snapshot time is reset.

Vulnerability Details

https://github.com/Cyfrin/2024-10-sablier/blob/963bf61b9d8ffe3eb06cbcf1c53f0ab88dbf0eb0/src/SablierFlow.sol#L826-L836

if (amountScaled <= _streams[streamId].snapshotDebtScaled) {
_streams[streamId].snapshotDebtScaled -= amountScaled;
}
else {
_streams[streamId].snapshotDebtScaled = totalDebtScaled - amountScaled;
// Effect: update the stream time.
_streams[streamId].snapshotTime = uint40(block.timestamp);
}

if you look at this test below

function testPartialWithdrawalsWithSnapshot() public {
// Create stream with 100 tokens at 1 token per second
vm.startPrank(sender);
streamId = sablier.createAndDeposit(sender, recipient, ratePerSecond, IERC20(address(token)), true, 100e18);
vm.stopPrank();
// Fast forward 30 seconds
vm.warp(block.timestamp + 30);
// First withdrawal less than snapshot debt
vm.prank(recipient);
(uint128 withdrawn1,) = sablier.withdraw(streamId, recipient, 15e18);
// Fast forward 20 seconds
vm.warp(block.timestamp + 20);
// Second withdrawal
vm.prank(recipient);
uint128 withdrawable = uint128(sablier.withdrawableAmountOf(streamId));
(uint128 withdrawn2,) = sablier.withdraw(streamId, recipient, 25e18);
// Verify total withdrawn matches expected amount
uint256 totalWithdrawn = withdrawn1 + withdrawn2;
uint256 expectedStreamed = 50e18; // 50 seconds * 1 token per second
assertEq(totalWithdrawn, expectedStreamed, "Incorrect total withdrawn amount");
}

Test Results:

[FAIL: Incorrect total withdrawn amount: 40000000000000000000 != 50000000000000000000] testPartialWithdrawalsWithSnapshot()
Logs:
First withdrawal amount: 15000000000000000000
Total debt after first withdrawal: 15000000000000000000
Withdrawable amount after first withdrawal: 15000000000000000000
Withdrawable amount before second withdrawal: 35000000000000000000
Second withdrawal amount: 25000000000000000000
Total withdrawn: 40000000000000000000
Expected streamed: 50000000000000000000
Final total debt: 10000000000000000000

The test demonstrates that:

  1. After 30 seconds, user withdraws 15e18 tokens

  2. After 50 seconds total (20 more seconds), user should be able to withdraw 35e18 more tokens

  3. Actually receives only 25e18 in second withdrawal

  4. Total withdrawn (40e18) is less than entitled amount (50e18)

Impact

Users receive 20% less funds than entitled (demonstrated in test)
Funds remain locked in the contract
Affects all streams where withdrawals exceed snapshot debt

Tools Used

Manual code review
Unit tests:
testPartialWithdrawalsWithSnapshot()
testSnapshotDebtAccounting()
testStreamSolvency()

Recommendations

Replace the current withdrawal logic with:

if (amountScaled <= _streams[streamId].snapshotDebtScaled) {
_streams[streamId].snapshotDebtScaled -= amountScaled;
} else {
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
uint256 remainingAmount = amountScaled - _streams[streamId].snapshotDebtScaled;
// Calculate how much time of ongoing debt we're consuming
uint256 consumedTime = (remainingAmount * 1e18) / _streams[streamId].ratePerSecond.unwrap();
// Update snapshot time proportionally
_streams[streamId].snapshotTime = uint40(_streams[streamId].snapshotTime + consumedTime);
// Clear snapshot debt since we've consumed it
_streams[streamId].snapshotDebtScaled = 0;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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