Flow

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

The discrete streaming nature of a `Flow Stream` plus the open access of the `withdraw` function will cause annoying delay for the recipient of a `Flow Stream`

Summary

The Sablier FlowStream doesn't have a continuous streaming nature when streaming a low decimal token. Rather, it has a discrete streaming nature. This simply means that there are some points in time when no token will be accrued. Knowing this, plus the fact that the protocol allows any random address to call the withdraw function, can cause an annoying delay in withdrawing the recipient's desired amount from the stream whenever the sender streams a high-value token with low decimal and very low rps

Vulnerability Details

The Sablier Protocol explains how a Sablier Flowstream will become a discrete function of funds instead of a continuous function of funds with respect to time in this doc https://github.com/sablier-labs/flow/blob/b01cc2daf6493ae792a858d6179facc6250403e2/TECHNICAL-DOC.md particularly in the Delay due to Descaling (unlock interval) section of the docs. The fact that a Sablier Flowstream becomes a discrete stream instead of a continuous stream when a sender is streaming a low decimal token, plus the fact that the protocol allows any address to call the withdrawfunction on the Sablier Flowcontract on behalf of the recipient, creates a room where if anyone for any reason is interested in delaying the recipient from getting their desired amount from the stream at the appropriate time they can easily do that.

The below is a test to show the discrete behavior of the Sablier Flowstream when streaming a very low decimal token at a very low rps.copy the below test and paste it in withdrawDelay.t.solyou will find it inside the concretefolder that is inside the integrationfolder which is inside the testfolder.

function test_withdraw_Unbearable1() external {
uint128 rps = 0.000000000002040833e18;
// uint128 rps = 0.000000000023040833e18;
vm.warp(OCT\_1\_2024);
uint256 streamId = flow\.createAndDeposit(users.sender, users.recipient, ud21x18(rps), usdc, true, 0.001e6);
uint40 initialSnapshotTime = OCT\_1\_2024;
// at one day of streaming the stream haven't accumulates up to 1 wei of the token
vm.warp(initialSnapshotTime + 86\_400 seconds);
console2.log(
"the amount of wei this stream have accrued at timestamp 86\_400(one day of actively streaming is)",
getDescaledAmount(flow\.ongoingDebtScaledOf(streamId), flow\.getTokenDecimals(streamId))
);
// at one and half days of streaming the stream haven't accumulated up to 1 wei of the token
vm.warp(initialSnapshotTime + 129\_600 seconds);
console2.log(
"the amount of wei this stream have accrued at timestamp 129\_600(one and half day of actively streaming is)",
getDescaledAmount(flow\.ongoingDebtScaledOf(streamId), flow\.getTokenDecimals(streamId))
);
// at two days of streaming the stream haven't accumulated up to 1 wei of the token
vm.warp(initialSnapshotTime + 172\_800);
console2.log(
"the amount of wei this stream have accrued at timestamp 172800(two days of actively streaming is)",
getDescaledAmount(flow\.ongoingDebtScaledOf(streamId), flow\.getTokenDecimals(streamId))
);
// only at one week the stream have accrued 1 wei of the token
vm.warp(initialSnapshotTime + 604\_800 seconds);
console2.log(
"the amount of wei this stream have accrued at timestamp 604\_800(one week of actively streaming is)",
getDescaledAmount(flow\.ongoingDebtScaledOf(streamId), flow\.getTokenDecimals(streamId))
);
// assertEq(getDescaledAmount(flow\.ongoingDebtScaledOf(streamId), flow\.getTokenDecimals(streamId)), 1);
// at one week and 4 days the stream have only accrued 1 wei still
vm.warp(initialSnapshotTime + 950\_400 seconds);
console2.log(
"the amount of wei this stream have accrued at timestamp 950\_400(one week and four days of actively streaming is)",
getDescaledAmount(flow\.ongoingDebtScaledOf(streamId), flow\.getTokenDecimals(streamId))
);
// at one week and 5 days the stream have only accrued 2 wei
vm.warp(initialSnapshotTime + 1\_036\_800 seconds);
console2.log(
"the amount of wei this stream have accrued at timestamp 1\_036\_800(one week and five days of actively streaming is)",
getDescaledAmount(flow\.ongoingDebtScaledOf(streamId), flow\.getTokenDecimals(streamId))
);
}

Run the test with forge test --mt test_withdraw_Unbearable -vvvvcommand and you will get the below output showing the discrete behavior of the stream

[⠒] Compiling...
[⠘] Compiling 1 files with Solc 0.8.26
[⠃] Solc 0.8.26 finished in 5.75s
Compiler run successful!
Ran 1 test for tests/integration/concrete/withdraw-delay/withdrawDelay.t.sol:WithdrawDelay_Integration_Concrete_Test
[PASS] test_withdraw_Unbearable() (gas: 219855)
Logs:
the amount of wei this stream has accrued at timestamp 86_400(one day of actively streaming is) 0
the amount of wei this stream has accrued at timestamp 129_600(one and half day of actively streaming is) 0
the amount of wei this stream has accrued at timestamp 172800(two days of actively streaming is) 0
the amount of wei this stream has accrued at timestamp 604_800(one week of actively streaming is) 1
the amount of wei this stream has accrued at timestamp 950_400(one week and four days of actively streaming is) 1
the amount of wei this stream has accrued at timestamp 1_036_800(one week and five days of actively streaming is) 2
Traces:
[219855] WithdrawDelay_Integration_Concrete_Test::test_withdraw_Unbearable()
├─ [0] VM::warp(1727740800 [1.727e9])
│ └─ ← [Return]
├─ [173102] Flow::createAndDeposit(sender: [0xCD1722F3947DEf4Cf144679Da39c4c32BDC35681], recipient: [0x006217c47ffA5Eb3F3c92247ffFE22AD998242c5], 2040833 [2.04e6], USDC: [0xa0Cb889
707d426A7A386870A03bc70d1b0697598], true, 1000) │ ├─ [205] USDC::decimals() [staticcall]
│ │ └─ ← [Return] 6
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: recipient: [0x006217c47ffA5Eb3F3c92247ffFE22AD998242c5], tokenId: 2)
│ ├─ emit MetadataUpdate(_tokenId: 2)
│ ├─ emit CreateFlowStream(streamId: 2, sender: sender: [0xCD1722F3947DEf4Cf144679Da39c4c32BDC35681], recipient: recipient: [0x006217c47ffA5Eb3F3c92247ffFE22AD998242c5], ratePerS
econd: 2040833 [2.04e6], token: USDC: [0xa0Cb889707d426A7A386870A03bc70d1b0697598], transferable: true) │ ├─ [32418] USDC::transferFrom(sender: [0xCD1722F3947DEf4Cf144679Da39c4c32BDC35681], Flow: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000)
│ │ ├─ emit Transfer(from: sender: [0xCD1722F3947DEf4Cf144679Da39c4c32BDC35681], to: Flow: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], value: 1000)
│ │ └─ ← [Return] true
│ ├─ emit DepositFlowStream(streamId: 2, funder: sender: [0xCD1722F3947DEf4Cf144679Da39c4c32BDC35681], amount: 1000)
│ └─ ← [Return] 2
├─ [0] VM::warp(1727827200 [1.727e9])
│ └─ ← [Return]
├─ [1161] Flow::ongoingDebtScaledOf(2) [staticcall]
│ └─ ← [Return] 176327971200 [1.763e11]
├─ [796] Flow::getTokenDecimals(2) [staticcall]
│ └─ ← [Return] 6
├─ [0] console::log("the amount of wei this stream have accrued at timestamp 86_400(one day of actively streaming is)", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::warp(1727870400 [1.727e9])
│ └─ ← [Return]
├─ [1161] Flow::ongoingDebtScaledOf(2) [staticcall]
│ └─ ← [Return] 264491956800 [2.644e11]
├─ [796] Flow::getTokenDecimals(2) [staticcall]
│ └─ ← [Return] 6
├─ [0] console::log("the amount of wei this stream have accrued at timestamp 129_600(one and half day of actively streaming is)", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::warp(1727913600 [1.727e9])
│ └─ ← [Return]
├─ [1161] Flow::ongoingDebtScaledOf(2) [staticcall]
│ └─ ← [Return] 352655942400 [3.526e11]
├─ [796] Flow::getTokenDecimals(2) [staticcall]
│ └─ ← [Return] 6
├─ [0] console::log("the amount of wei this stream have accrued at timestamp 172800(two days of actively streaming is)", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::warp(1728345600 [1.728e9])
│ └─ ← [Return]
├─ [1161] Flow::ongoingDebtScaledOf(2) [staticcall]
│ └─ ← [Return] 1234295798400 [1.234e12]
├─ [796] Flow::getTokenDecimals(2) [staticcall]
│ └─ ← [Return] 6
├─ [0] console::log("the amount of wei this stream have accrued at timestamp 604_800(one week of actively streaming is)", 1) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::warp(1728691200 [1.728e9])
│ └─ ← [Return]
├─ [1161] Flow::ongoingDebtScaledOf(2) [staticcall]
│ └─ ← [Return] 1939607683200 [1.939e12]
├─ [796] Flow::getTokenDecimals(2) [staticcall]
│ └─ ← [Return] 6
├─ [0] console::log("the amount of wei this stream have accrued at timestamp 950_400(one week and four days of actively streaming is)", 1) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::warp(1728777600 [1.728e9])
│ └─ ← [Return]
├─ [1161] Flow::ongoingDebtScaledOf(2) [staticcall]
│ └─ ← [Return] 2115935654400 [2.115e12]
├─ [796] Flow::getTokenDecimals(2) [staticcall]
│ └─ ← [Return] 6
├─ [0] console::log("the amount of wei this stream have accrued at timestamp 1_036_800(one week and five days of actively streaming is)", 2) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 38.44ms (4.56ms CPU time)
Ran 1 test suite in 1.95s (38.44ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

From the test output above, we can clearly see how discrete the stream is. The stream was only able to accrue 1 wei of the token it is streaming after one week of actively streaming, and it was only able to accrue 2 wei of the token it is streaming after one week and five days of actively streaming. Now that we have seen how much delay there can be for a low decimal and low rps stream to increase its accrued amount from 1 wei to 2 wei, I think it is time to understand the vulnerability I am pointing out. If, for any reason, there is anyone who does not want the recipient of this stream to receive the 2 wei from the stream at a week and five days of the stream actively streaming, they just need to call the withdraw function on the SablierFlowcontract at a week and four days of streaming, and that will withdraw only 1 wei to the recipient who has been waiting all along to withdraw their 2 wei the next day. This will be really annoying and frustrating to the recipient. You can argue that the user doesn't lose any funds and can still withdraw another 1 wei to complete the 2 wei, and that is correct, but the problem is the recipient will now have to wait for a whole week to get the 1 wei which they would have gotten the next day if no one had called the withdraw function on the SablierFlowcontract on their behalf.

Impact

This vulnerability leads to the recipient having to wait unnecessarily to withdraw their desired amount from the stream.

Tools Used

Manual Review

Recommendations

The protocol should restrict the withdrawfunction to the recipient and the sender as those are the people who have a trust base relationship, and we can assume that the sender will not do anything to harm the recipient, but we can't assume that any random address will not do anything to harm the recipient.

Updates

Lead Judging Commences

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Appeal created

engrpips Submitter
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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