Flow

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

SablierFlow Fails to Account for Transfer Fees in ERC20 Tokens, Leading to Balance Discrepancies

Summary

The SablierFlow contract does not correctly handle ERC20 tokens that implement transfer fees (i.e., tokens that deduct a fee upon transfer). When users interact with such tokens, the contract's balance calculations become inaccurate, leading to potential loss of funds, incorrect stream balances, and unexpected behaviors.

Vulnerability Details

Description

The SablierFlow contract is designed to handle token streams by transferring tokens from the sender to the recipient over time. However, it assumes that the amount of tokens transferred equals the amount specified in the deposit function. This assumption fails with ERC20 tokens that implement a transfer fee (also known as deflationary tokens), where a percentage of the transferred amount is deducted as a fee.

In the provided test case, a MockTransferFeeToken with a 2% transfer fee is used. When the sender attempts to deposit 100 tokens into the stream, only 98 tokens are actually transferred to the SablierFlow contract due to the 2% fee. The contract, unaware of this discrepancy, still believes it has received 100 tokens, leading to incorrect balance calculations.

Proof of Concept

  1. First create the mock file: tests/mocks/MockTransferFeeToken.sol with this content:

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    // Import the OpenZeppelin ERC20 implementation
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    /**
    * @title MockTransferFeeToken
    * @dev ERC20 token with a transfer fee mechanism. A percentage of each transfer is deducted as a fee.
    */
    contract MockTransferFeeToken is ERC20 {
    uint8 private _decimals;
    uint256 public feePercentage; // Fee percentage (e.g., 2 for 2%)
    /**
    * @dev Constructor that initializes the token with a name, symbol, decimals, and transfer fee percentage.
    * @param name_ The name of the token.
    * @param symbol_ The symbol of the token.
    * @param decimals_ The number of decimals the token uses.
    * @param feePercentage_ The transfer fee percentage (e.g., 2 for 2%).
    */
    constructor(
    string memory name_,
    string memory symbol_,
    uint8 decimals_,
    uint256 feePercentage_
    ) ERC20(name_, symbol_) {
    _decimals = decimals_;
    feePercentage = feePercentage_;
    }
    /**
    * @dev Overrides the decimals function to allow setting custom decimals.
    * @return The number of decimals the token uses.
    */
    function decimals() public view virtual override returns (uint8) {
    return _decimals;
    }
    /**
    * @dev Mints tokens to a specified address. This function is intended for testing purposes.
    * @param to The address to mint tokens to.
    * @param amount The amount of tokens to mint.
    */
    function mint(address to, uint256 amount) external {
    _mint(to, amount);
    }
    /**
    * @dev Overrides the ERC20 `transfer` function to include a transfer fee.
    * @param recipient The address to transfer tokens to.
    * @param amount The total amount of tokens to transfer before fees.
    * @return A boolean value indicating whether the operation succeeded.
    */
    function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
    uint256 fee = (amount * feePercentage) / 100; // Calculate the fee based on the percentage
    uint256 amountAfterFee = amount - fee;
    // Transfer the fee to the contract itself (can be modified to send to a specific fee collector)
    _transfer(_msgSender(), address(this), fee);
    // Transfer the remaining amount to the recipient
    _transfer(_msgSender(), recipient, amountAfterFee);
    return true;
    }
    /**
    * @dev Overrides the ERC20 `transferFrom` function to include a transfer fee.
    * @param sender The address to transfer tokens from.
    * @param recipient The address to transfer tokens to.
    * @param amount The total amount of tokens to transfer before fees.
    * @return A boolean value indicating whether the operation succeeded.
    */
    function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {
    uint256 fee = (amount * feePercentage) / 100; // Calculate the fee based on the percentage
    uint256 amountAfterFee = amount - fee;
    // Transfer the fee to the contract itself (can be modified to send to a specific fee collector)
    _transfer(sender, address(this), fee);
    // Transfer the remaining amount to the recipient
    _transfer(sender, recipient, amountAfterFee);
    // Decrease the allowance
    uint256 currentAllowance = allowance(sender, _msgSender());
    require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");
    unchecked {
    _approve(sender, _msgSender(), currentAllowance - amount);
    }
    return true;
    }
    }
  2. Create the main test file: tests/SablierFlowTransferFeeTest.t.sol with the following content and then run the test with command: forge test --mt testDepositWithTransferFee -vvvv

    // SPDX-License-Identifier: MIT
    pragma solidity >=0.8.22;
    import "forge-std/src/Test.sol";
    import "../src/SablierFlow.sol";
    import "./mocks/MockTransferFeeToken.sol"; // Token with transfer fee
    /**
    * @title SablierFlowTransferFeeTest
    * @dev This contract tests the SablierFlow contract's handling of ERC20 tokens that implement transfer fees.
    * Specifically, it verifies whether the contract correctly accounts for the actual amount received after fees.
    */
    contract SablierFlowTransferFeeTest is Test {
    // Instance of the SablierFlow contract
    SablierFlow sablierFlow;
    // Instance of the MockTransferFeeToken with a transfer fee
    MockTransferFeeToken token;
    // Address representing the sender of the tokens
    address sender = address(0x123);
    // Address representing the recipient of the tokens
    address recipient = address(0x456);
    /**
    * @dev The `setUp` function is executed before each test.
    * It deploys the necessary contracts, mints tokens, and sets up approvals.
    */
    function setUp() public {
    // Deploy the MockTransferFeeToken with a 2% transfer fee
    token = new MockTransferFeeToken("Transfer Fee Token", "TFT", 18, 2); // 2% fee on transfers
    emit log_named_address("MockTransferFeeToken deployed to", address(token));
    // Deploy the SablierFlow contract with a mock NFT descriptor (address(0) for simplicity)
    sablierFlow = new SablierFlow(address(this), IFlowNFTDescriptor(address(0)));
    emit log_named_address("SablierFlow deployed to", address(sablierFlow));
    // Mint 1,000 tokens to the sender's address
    token.mint(sender, 1000 ether);
    emit log_named_uint("Sender's initial token balance", token.balanceOf(sender));
    // Simulate the sender approving the SablierFlow contract to spend their tokens
    vm.prank(sender);
    token.approve(address(sablierFlow), 1000 ether);
    emit log_named_address("Sender approved SablierFlow to spend tokens", sender);
    }
    /**
    * @dev Tests the deposit functionality of the SablierFlow contract when using tokens with transfer fees.
    * It verifies that the contract correctly accounts for the reduced amount received after fees.
    */
    function testDepositWithTransferFee() public {
    emit log("=== Starting test: testDepositWithTransferFee ===");
    // Step 1: Simulate the sender creating a token stream to the recipient
    vm.prank(sender);
    emit log("Sender is creating a stream with a rate of 1 token per second.");
    uint256 streamId = sablierFlow.create(
    sender,
    recipient,
    ud21x18(1 ether), // ratePerSecond is 1 token per second
    token,
    true // The stream is transferable
    );
    emit log_named_uint("Stream created with ID", streamId);
    // Step 2: Sender deposits 100 tokens into the stream
    vm.prank(sender);
    emit log("Sender is depositing 100 tokens into the stream.");
    sablierFlow.deposit(streamId, 100 ether, sender, recipient);
    emit log("Deposit transaction submitted.");
    // Step 3: Retrieve the stream's balance after the deposit
    uint128 streamBalance = sablierFlow.getBalance(streamId);
    emit log_named_uint("Stream balance after deposit (expected 98 tokens due to 2% fee)", streamBalance);
    // Step 4: Assert that the stream balance reflects the fee deduction
    assertEq(
    streamBalance,
    98 ether,
    "Stream balance should be 98 tokens after applying a 2% transfer fee on a 100 token deposit."
    );
    emit log("Assertion passed: Stream balance correctly accounts for transfer fee.");
    // Step 5: Verify the sender's remaining balance after the deposit
    uint256 senderBalance = token.balanceOf(sender);
    emit log_named_uint("Sender's remaining token balance", senderBalance);
    assertEq(
    senderBalance,
    900 ether,
    "Sender should have 900 tokens remaining after depositing 100 tokens."
    );
    emit log("Assertion passed: Sender's balance correctly updated after deposit.");
    // Step 6: Verify the SablierFlow contract's token balance reflects the actual received amount
    uint256 contractBalance = token.balanceOf(address(sablierFlow));
    emit log_named_uint("SablierFlow contract's token balance", contractBalance);
    assertEq(
    contractBalance,
    98 ether,
    "SablierFlow contract should hold 98 tokens after receiving a 100 token deposit with a 2% fee."
    );
    emit log("Assertion passed: SablierFlow contract's balance correctly reflects the fee-adjusted deposit.");
    emit log("=== Test testDepositWithTransferFee completed successfully ===");
    }
    }

Reproduction Steps

  1. Deploy Contracts and Setup

    • Deploy the MockTransferFeeToken with a 2% transfer fee.

    • Deploy the SablierFlow contract.

  2. Mint and Approve Tokens

    • Mint 1,000 tokens to the sender.

    • Approve the SablierFlow contract to spend the sender's tokens.

  3. Create a Stream

    • The sender creates a stream to the recipient with a rate of 1 token per second.

  4. Deposit Tokens

    • The sender deposits 100 tokens into the stream.

    • Due to the 2% transfer fee, only 98 tokens are actually transferred to the SablierFlow contract.

  5. Check Stream Balance

    • Retrieve the balance of the stream using getBalance(streamId).

    • Observe that the balance is 98 tokens instead of the expected 100 tokens.

Root Cause

The SablierFlow contract does not account for the possibility that the amount of tokens transferred might be less than the amount specified due to transfer fees. It assumes a 1:1 correspondence between the intended transfer amount and the actual amount received. This assumption fails with tokens that implement a transfer fee, leading to incorrect balance tracking and potential loss of funds.

Impact

  • Incorrect Balance Calculations: The contract overestimates the tokens it holds, leading to mismanagement of the token streams.

  • Loss of Funds: Recipients may receive fewer tokens than expected, and senders might be charged for tokens that are not properly allocated.

  • Contract Malfunction: Functions that rely on accurate balance calculations may fail or behave unpredictably.

  • Security Risks: Attackers might exploit this discrepancy to manipulate stream balances or drain funds.

Tools Used

  • Foundry: A smart contract development and testing framework.

  • Solidity Compiler: Version 0.8.22.

  • Mock Contracts: MockTransferFeeToken to simulate a token with a transfer fee.

  • Forge Test Suite: To write and execute the test case demonstrating the vulnerability.

Recommendations

  1. Implement Safe Transfer Mechanisms

    Use OpenZeppelin's SafeERC20 library, specifically the safeTransferFrom function, which checks the actual amount of tokens transferred.

    using SafeERC20 for IERC20;
    function deposit(uint256 streamId, uint256 amount) external {
    uint256 balanceBefore = token.balanceOf(address(this));
    token.safeTransferFrom(msg.sender, address(this), amount);
    uint256 balanceAfter = token.balanceOf(address(this));
    uint256 actualReceived = balanceAfter - balanceBefore;
    // Use actualReceived instead of amount
    streams[streamId].balance += actualReceived;
    }
  2. Adjust Balance Calculations

    • Calculate the difference between the contract's balance before and after the transfer to determine the actual amount received.

    • Update internal accounting to use the actual received amount.

  3. Validate Token Compatibility

    • Implement checks to ensure that only tokens without transfer fees are allowed, or

    • Maintain a whitelist of supported tokens known to be compatible.

  4. User Notifications

    • Inform users attempting to use tokens with transfer fees about potential issues.

    • Provide clear error messages when unsupported tokens are used.

  5. Comprehensive Testing

    • Include test cases with tokens that have transfer fees, deflationary tokens, or tokens with other non-standard behaviors.

    • Use mock tokens to simulate various ERC20 token behaviors during testing.

  6. Audit Third-Party Tokens

    • Before accepting a token into the platform, audit its transfer behavior to ensure compatibility.

  7. Fallback Mechanisms

    • Implement fallback logic to handle cases where the transferred amount is less than expected, possibly reverting the transaction with an informative error message.

By implementing these recommendations, the SablierFlow contract can safely interact with a wider range of ERC20 tokens, including those with transfer fees, and prevent potential loss of funds or incorrect stream balances.

Updates

Lead Judging Commences

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

Support

FAQs

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