Sablier

Sablier
DeFiFoundry
53,440 USDC
View results
Submission Details
Severity: high
Invalid

Due to current logic streamedAmount calculation the streamed amount is calcultated a lot more than it should which results loss of funds of sender while refunding

Summary

The streamed amount is calculated for multi-segment streams in such a way which results loss of funds of sender in refund if he cancel the stream.

Vulnerability Details

In a multi-segment stream each segment has its own function to calculate the streamed amount of that segment, the formula is:
$$
f(x) = x^{exp} * csa + \Sigma(esa)
$$
So suppose a stream of 4 segments looks like this, where each segment is 60 seconds long & exponent is 2.

X a b c d <----- timestamps
|''''''''''''''''''''''|''''''''''''''''''''''''|''''''''''''''''''''|''''''''''''''''''|
| 100 | 100 | 100 | 100 | <---- amounts
| 0 | 1 | 2 | 3 | <---- Indexes
|''''''''''''''''''''''|''''''''''''''''''''''''|''''''''''''''''''''||'''''''''''''''''|
|
|
block.timestamp ( T )

So here, startTime is X. Now endTime/timestamp of 1st segment is ( X + 60 seconds), timestamp of 2nd segment is (X+120 seconds), timestamp of 3rd segment is (X + 180 seconds) & timestamp of 4th segment is (X+240 seconds). Now if I wanna check the streamed amount at (X + 181) seconds i.e just after 1 second entering the 4th segment ( I denoted that timestamp with T ) the streamed amount should be little more than 300 because till 3rd segment 300 was streamed. Let's calculate the streamed amount for (X + 181) seconds timestamp:

x = (1/60) where 1 is elapsed time in the segment & 60 is total time of the segment.
exp = 2
csa = 100
Σ(esa) = 300
=> (1÷60)^2×100+300 => 300.027777778

But as per implemented logic the value returns = 399 i.e almost total streamed amount till 4th segment.

Create a file in /test directory and paste this:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import {Base_Test} from "./Base.t.sol";
import "forge-std/src/console.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Broker, Lockup, LockupDynamic } from "../src/types/DataTypes.sol";
import { UD2x18 } from "@prb/math/src/UD2x18.sol";
import {PRBMathCastingUint256} from "@prb/math/src/casting/Uint256.sol";
contract TestDynamic is Base_Test{
uint40 public Time;
UD2x18 public exp;
Broker broker;
LockupDynamic.CreateWithTimestamps public createWithTimestamps;
mapping(uint => LockupDynamic.Segment) segments;
function setUp() public override{
Base_Test.setUp();
Time = uint40(block.timestamp);
exp = PRBMathCastingUint256.intoUD2x18(2);
setStructs();
}
function setStructs() public {
uint40 _timestamp = Time + 120 seconds;
for(uint i; i < 5; i++){
segments[i]=LockupDynamic.Segment({amount: 100, exponent: exp, timestamp: _timestamp});
createWithTimestamps.segments.push(segments[i]);
_timestamp += 60 seconds;
}
broker = Broker({account: users.broker, fee: PRBMathCastingUint256.intoUD60x18(0)});
createWithTimestamps.sender = users.alice;
createWithTimestamps.recipient = users.recipient;
createWithTimestamps.totalAmount = 500;
createWithTimestamps.asset = dai;
createWithTimestamps.cancelable = true;
createWithTimestamps.transferable = true;
createWithTimestamps.startTime = Time + 59 seconds; //@note as startTime can't be >= segments[0].timestamp
createWithTimestamps.broker = broker;
console.log("Length of createWithTimestamps.segments:", createWithTimestamps.segments.length);
}
//* endTime of 1st segment = Time+120 seconds, endTIme of 2nd segment = Time + 180 seconds, endTIme of 3rd segment = Time + 240 seconds,
//* endTIme of 4th segment = Time + 300 seconds. We will calculate the streamed amount between 3 & 4 segment i.e at Time + 241 seconds.
function test_lessStreamedAmount() public {
vm.stopPrank();
vm.startPrank(users.alice);
lockupDynamic.createWithTimestamps(createWithTimestamps);
assertTrue(lockupDynamic.isStream(1));
assertNotEq(block.timestamp, createWithTimestamps.segments[4].timestamp);
//@note We just completed 3rd segment timestamp and passed just 1 second of 4th segment
skip(241 seconds);
uint streamedAmount = lockupDynamic.streamedAmountOf(1);
console.log("Deposited amount:", lockupDynamic.getDepositedAmount(1));
console.log("Streamed amount:", streamedAmount);
assertEq(streamedAmount, 399);
console.log("Refundable amount:", lockupDynamic.refundableAmountOf(1));
assertEq(IERC20(dai).balanceOf(address(users.alice)), 999999999999999999999500); // As alice deposited 500 in stream
lockupDynamic.cancel(1);
assertEq(IERC20(dai).balanceOf(address(users.alice)), 999999999999999999999601);
}
}

Logs:

2024-05-Sablier/v2-core main*​​ via 🍞 v1.1.8 via  v18.17.1
❯ forge test --mc TestDynamic -vv
[⠊] Compiling...
[⠰] Compiling 1 files with 0.8.23
[⠔] Solc 0.8.23 finished in 1.32s
Compiler run successful!
Ran 1 test for test/TestDynamic.t.sol:TestDynamic
[PASS] test_lessStreamedAmount() (gas: 500366)
Logs:
Length of createWithTimestamps.segments: 5
Deposited amount: 500
Streamed amount: 399
Refundable amount: 101
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.02ms (595.69µs CPU time)

As you can see the refundable amount is 101, but it should be almost 200. So here sender will loss his fund in refunding if he cancel the stream.

Impact

The sender will loss fund while refunding.

Tools Used

Manual review, Foundry

Recommendations

Recheck the logic of calculating the streamed amount.

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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