Flow

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

Insufficient balance validation in stream restart function

Summary

In SablierFlow the _restart function lacks proper balance validation, allowing streams to be restarted with zero or insufficient balance. This can lead to streams appearing active despite having little or no funds to fulfill their obligations, which will create misleading states and accounting inconsistencies.

Vulnerability Details

This can be found in the _restart function of the SablierFlow.sol contract. The function only checks if the stream is paused but fails to validate if there's sufficient balance to restart the stream.

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

function _restart(uint256 streamId, UD21x18 ratePerSecond) internal {
// Check: the stream is not paused.
if (_streams[streamId].ratePerSecond.unwrap() != 0) {
revert Errors.SablierFlow_StreamNotPaused(streamId);
}
// Checks and Effects: update the rate per second and the snapshot time.
_adjustRatePerSecond({ streamId: streamId, newRatePerSecond: ratePerSecond });
// Log the restart.
emit ISablierFlow.RestartFlowStream(streamId, msg.sender, ratePerSecond);
}

Impact

This allows streams to be restarted without sufficient funds, leading to:
Streams appearing active but unable to transfer funds
Misleading status information
Potential accounting inconsistencies
False expectations for recipients

Proof of Concept test and results:

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.22;
import { Test } from "forge-std/src/Test.sol";
import { SablierFlow } from "../src/SablierFlow.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { ud21x18 } from "@prb/math/src/UD21x18.sol";
import { IFlowNFTDescriptor } from "../src/interfaces/IFlowNFTDescriptor.sol";
import { Flow } from "../src/types/DataTypes.sol";
// Mock ERC20 Token contract
contract MockERC20 is ERC20 {
constructor() ERC20("Mock Token", "MTK") {
_mint(msg.sender, 1_000_000e18);
}
function decimals() public pure override returns (uint8) {
return 18;
}
}
contract SablierFlowRestartTest is Test {
SablierFlow public sablierFlow;
IERC20 public mockToken;
address public sender;
address public recipient;
uint256 public streamId;
function setUp() public {
// Deploy mock token
mockToken = new MockERC20();
// Deploy SablierFlow
sablierFlow = new SablierFlow(address(this), IFlowNFTDescriptor(address(0)));
// Setup test addresses
sender = address(1);
recipient = address(2);
// Give sender some tokens and approve SablierFlow
vm.startPrank(sender);
deal(address(mockToken), sender, 1000e18);
mockToken.approve(address(sablierFlow), type(uint256).max);
vm.stopPrank();
}
function testRestartWithZeroBalance() public {
// Create a stream with initial rate
vm.startPrank(sender);
streamId = sablierFlow.create(
sender,
recipient,
ud21x18(1e18), // 1 token per second
mockToken,
true
);
// Pause the stream
sablierFlow.pause(streamId);
// Get balance before restart
uint256 balanceBefore = mockToken.balanceOf(address(sablierFlow));
// Attempt to restart the stream without any balance
vm.expectRevert(abi.encodeWithSignature("SablierFlow_InsufficientBalance(uint256)", streamId));
sablierFlow.restart(streamId, ud21x18(1e18));
// Verify nothing changed
uint256 balanceAfter = mockToken.balanceOf(address(sablierFlow));
assertEq(balanceAfter, balanceBefore);
assertEq(balanceAfter, 0);
vm.stopPrank();
}
function testRestartWithPartialBalance() public {
vm.startPrank(sender);
// Create and fund stream
streamId = sablierFlow.create(
sender,
recipient,
ud21x18(2e18), // 2 tokens per second
mockToken,
true
);
sablierFlow.deposit(
streamId,
10e18, // amount
sender, // sender
recipient // recipient
);
// Wait for some time to accumulate debt
vm.warp(block.timestamp + 5); // 5 seconds passed
// Pause the stream
sablierFlow.pause(streamId);
// Try to restart with insufficient balance for accumulated debt
vm.expectRevert(abi.encodeWithSignature("SablierFlow_InsufficientBalance(uint256)", streamId));
sablierFlow.restart(streamId, ud21x18(2e18));
// Verify stream status remains paused
assertTrue(sablierFlow.statusOf(streamId) == Flow.Status.PAUSED_INSOLVENT);
vm.stopPrank();
}
}

Run with: forge test --match-contract SablierFlowRestartTest -vvvv

Test Results:

[FAIL: next call did not revert as expected] testRestartWithZeroBalance() (gas: 205924)
[FAIL: next call did not revert as expected] testRestartWithPartialBalance() (gas: 265964)

Tools Used

Foundry Framework
Manual Code Review
Unit Testing

Recommendations

Implement proper balance validation in the _restart function:

function _restart(uint256 streamId, UD21x18 ratePerSecond) internal {
// Check: the stream is not paused.
if (_streams[streamId].ratePerSecond.unwrap() != 0) {
revert Errors.SablierFlow_StreamNotPaused(streamId);
}
// Check: the stream has sufficient balance
if (_streams[streamId].balance == 0) {
revert Errors.SablierFlow_InsufficientBalance(streamId);
}
// Check: the stream has sufficient balance to cover accumulated debt
uint256 totalDebt = _totalDebtOf(streamId);
if (_streams[streamId].balance < totalDebt) {
revert Errors.SablierFlow_InsufficientBalance(streamId);
}
// Checks and Effects: update the rate per second and the snapshot time.
_adjustRatePerSecond({ streamId: streamId, newRatePerSecond: ratePerSecond });
// Log the restart.
emit ISablierFlow.RestartFlowStream(streamId, msg.sender, ratePerSecond);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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