← Back to Blog

Fee on Transfer Token Vulnerability: Securing DeFi Pools

2026-07-06 solidity defi smart-contract-security erc20 rebasing-tokens fee-on-transfer auditing

In June 2020, a Balancer liquidity pool containing the deflationary Statera (STA) token was drained of approximately $500,000. The attacker did not exploit a vulnerability in the STA token contract itself, nor did they leverage an inherent coding flaw in Balancer's core mathematical logic. Instead, the exploit succeeded due to a catastrophic integration mismatch: the liquidity pool assumed standard ERC-20 behavior—where the amount of tokens transferred matches the amount of tokens received—while the token contract deducted a 1% fee on every transfer.

This mismatch represents the classic fee on transfer token vulnerability. In complex decentralized finance (DeFi) architectures, integrating non-standard ERC-20 tokens without proper accounting checks leads directly to protocol insolvency. This article analyzes the mechanics of fee-on-transfer (FoT) and rebasing tokens, details a compilable vulnerable contract, provides the exploit vectors, and presents secure implementation patterns to protect smart contracts from accounting discrepancies.


1. Mechanics of Non-Standard ERC-20 Tokens

DeFi protocols rely heavily on assumptions encoded within the ERC-20 standard. Two major classes of tokens break these assumptions: fee-on-transfer tokens and rebasing tokens.

Fee-on-Transfer (Deflationary) Tokens

Standard ERC-20 tokens execute transfers such that:

$$\text{Balance}{\text{after}} = \text{Balance}{\text{before}} + \text{amount}$$

Fee-on-transfer tokens, however, deduct a tax, burn fee, or protocol fee during the execution of transfer or transferFrom. The actual balance increase of the recipient is less than the requested transfer amount:

$$\text{Balance}{\text{after}} = \text{Balance}{\text{before}} + \text{amount} - \text{fee}$$

When a vault or liquidity pool executes transferFrom(sender, address(this), amount) and updates its internal ledger by adding amount to the sender's balance, it records an artificial surplus. The contract's internal accounting state drifts away from its physical token balance on-chain.

Rebasing (Elastic Supply) Tokens

Rebasing tokens dynamically adjust their total supply to maintain a peg or target price. These supply adjustments are applied globally across all token holders.
* Positive Rebase: The total supply increases, and every token holder's balance increases proportionally without any transfer occurring.
* Negative Rebase: The total supply decreases, and every token holder's balance decreases proportionally.

If a DeFi vault caches the depositor's balance using an internal mapping (e.g., balances[user] = amount) and does not update it during a rebase, the pool becomes vulnerable. In a negative rebase scenario, the actual contract balance falls below the sum of all cached user balances. Early withdrawers can claim their full cached balance, while late withdrawers face reverted transactions due to insufficient funds in the vault.


2. Vulnerable Implementation: Staking and Accounting Code

The contract below represents a typical DeFi staking pool. It tracks user deposits internally, assuming that the amount passed to transferFrom is exactly the amount received by the contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

/**
 * @title VulnerableVault
 * @notice A staking vault vulnerable to fee-on-transfer and rebasing accounting mismatches.
 */
contract VulnerableVault {
    IERC20 public immutable token;

    mapping(address => uint256) public balances;
    uint256 public totalDeposits;

    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);

    constructor(address _token) {
        require(_token != address(0), "Invalid token address");
        token = IERC20(_token);
    }

    /**
     * @notice Deposit tokens into the vault.
     * @dev Vulnerable to fee-on-transfer tokens. The internal ledger assumes 
     *      the 'amount' parameter is the exact balance increase.
     * @param amount The quantity of tokens to deposit.
     */
    function deposit(uint256 amount) external {
        require(amount > 0, "Amount must be greater than zero");

        // VULNERABLE: Does not check the actual balance increase of the vault
        require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");

        // Internal accounting is credited with the nominal amount, not the actual amount received
        balances[msg.sender] += amount; // <--- Vulnerability: Accounting mismatch
        totalDeposits += amount;

        emit Deposited(msg.sender, amount);
    }

    /**
     * @notice Withdraw tokens from the vault.
     * @param amount The quantity of tokens to withdraw.
     */
    function withdraw(uint256 amount) external {
        require(amount > 0, "Amount must be greater than zero");
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;
        totalDeposits -= amount;

        // The vault attempts to return the full nominal amount
        require(token.transfer(msg.sender, amount), "Transfer failed");

        emit Withdrawn(msg.sender, amount);
    }
}

Vulnerability Analysis

If an auditor or developer deploys VulnerableVault with a token that imposes a 5% transfer fee:
1. User A deposits 100 tokens.
2. The token contract executes the transfer, taking 5 tokens as a fee. The vault receives only 95 tokens.
3. VulnerableVault updates balances[User A] to 100 and totalDeposits to 100.
4. If User A immediately calls withdraw(100), the vault attempts to transfer 100 tokens to User A.
5. If User A is the sole depositor, the vault only holds 95 tokens on-chain. The withdraw call reverts due to insufficient contract balance, locking User A's funds.
6. If there are other depositors, User A successfully withdraws 100 tokens. However, the 5 tokens missing from the vault's physical balance are subsidized by other depositors' funds. The final depositor in the queue will be unable to withdraw their assets.


3. Exploit Walkthrough: How the Pool is Drained

To illustrate how this vulnerability escalates to a complete protocol drain, we analyze a scenario where a vault handles liquidity shares based on its internal records of deposits.

sequenceDiagram
    autonumber
    actor Attacker
    participant Vault as VulnerableVault
    participant Token as Fee-On-Transfer Token

    Attacker->>Token: approve(Vault, 100)
    Attacker->>Vault: deposit(100)
    Vault->>Token: transferFrom(Attacker, Vault, 100)
    Note over Token: Deducts 5% fee (5 tokens)
    Token-->>Vault: Return true (95 tokens received)
    Vault->>Vault: Credit Attacker with 100 internal balance
    Note over Vault: Accounting state: 100 units<br/>On-chain balance: 95 tokens
    Attacker->>Vault: withdraw(100)
    Vault->>Token: transfer(Attacker, 100)
    Note over Token: Deducts 5% fee (5 tokens)
    Token-->>Attacker: 95 tokens received
    Vault->>Vault: Debit Attacker 100 internal balance
    Note over Vault: Vault Insolvency: 0 units credited,<br/>but only 0 tokens remaining on-chain.

The Arbitrage and Liquidity Exploitation Loop

In automated market makers (AMMs) or lending platforms, this mismatch creates systemic arbitrage vectors:

  1. Price Manipulation: A liquidity pool calculates asset prices based on the ratio of balances. If the pool uses the cached balance (totalDeposits or a stored state variable) instead of querying the real balance (balanceOf), the computed price diverges from the real asset ratio.
  2. Infinite Swaps: In the Balancer STA exploit, the attacker executed flash loans to swap STA for other assets repeatedly. Each swap triggered the 1% fee, causing the physical STA balance in the pool to drop to near zero.
  3. Exploiting the Constant Product Invariant: Because the pool calculated swap rates using the cached STA balance (which remained constant) rather than the actual balance, the pool priced the remaining assets (WETH, LINK) as if the pool was still heavily collateralized with STA. The attacker purchased these valuable assets for a fraction of their market value.

4. Remediation: Secure Accounting and Mitigation Patterns

To secure smart contracts against fee-on-transfer and rebasing bugs, you must implement defensive accounting systems.

The Balance-Difference Pattern

The most robust mechanism to handle fee-on-transfer tokens is to query the contract's token balance immediately before and after the transfer operation. The difference between these two values represents the actual amount received.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

/**
 * @title SecureVault
 * @notice A vault that implements defensive balance checking to mitigate integration bugs.
 */
contract SecureVault {
    IERC20 public immutable token;

    mapping(address => uint256) public balances;
    uint256 public totalDeposits;

    event Deposited(address indexed user, uint256 actualAmount);
    event Withdrawn(address indexed user, uint256 amount);

    constructor(address _token) {
        require(_token != address(0), "Invalid token address");
        token = IERC20(_token);
    }

    /**
     * @notice Deposit tokens, calculating the net received amount after any fees.
     * @param amount The nominal amount of tokens requested for transfer.
     */
    function deposit(uint256 amount) external {
        require(amount > 0, "Amount must be greater than zero");

        // Record the contract balance prior to the transfer
        uint256 balanceBefore = token.balanceOf(address(this));

        // Execute the transfer
        require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");

        // Record the contract balance after the transfer
        uint256 balanceAfter = token.balanceOf(address(this));

        // Calculate the actual amount received by the contract
        uint256 actualAmount = balanceAfter - balanceBefore;
        require(actualAmount > 0, "No tokens received");

        // Credit the user with the actual tokens received, not the requested amount
        balances[msg.sender] += actualAmount;
        totalDeposits += actualAmount;

        emit Deposited(msg.sender, actualAmount);
    }

    /**
     * @notice Withdraw tokens. Note that the outbound transfer may also trigger a fee.
     * @param amount The quantity of tokens the user wishes to withdraw from their credit.
     */
    function withdraw(uint256 amount) external {
        require(amount > 0, "Amount must be greater than zero");
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;
        totalDeposits -= amount;

        // Perform the outbound transfer. The user will receive: amount - outbound_fee
        require(token.transfer(msg.sender, amount), "Transfer failed");

        emit Withdrawn(msg.sender, amount);
    }
}

Trade-offs of the Balance-Difference Pattern

While the balance-difference pattern successfully mitigates the fee-on-transfer vulnerability, it introduces design and gas trade-offs:

  1. Gas Cost: Every deposit now executes two external calls to balanceOf in addition to transferFrom. In high-throughput protocols, this increases transaction gas fees significantly.
  2. Reentrancy Risk: If the token contract has a callback mechanism (such as ERC-777 hooks), performing balance queries before and after external calls can expose the contract to read-only reentrancy if state variables are updated mid-execution. Always apply the checks-effects-interactions pattern and utilize reentrancy guards.

Alternative Securing Strategies


5. Case Study: The June 2020 Balancer STA Exploit

The Statera (STA) exploit remains the industry-defining example of fee-on-transfer integration issues. Below is a detailed breakdown of how the attacker manipulated the Balancer pool accounting:

  1. Pool Composition: The target pool held STA (1% transfer fee), WETH, LINK, SNX, and WBTC.
  2. Initial Setup: The attacker obtained a flash loan of WETH to establish a high-capital position.
  3. Execution Loop:
  4. The attacker swapped WETH for STA.
  5. The attacker swapped STA back to WETH.
  6. Because of the 1% transfer fee, each transfer of STA back to the pool resulted in the pool receiving less STA than expected.
  7. However, Balancer's internal logic did not query the actual token balance of the contract. It calculated prices based on cached state variables that assumed the full STA transfer amount was deposited.
  8. De-collateralization: As the attacker repeated the swaps, the physical balance of STA inside the pool fell to near zero, while the cached state variable remained extremely high.
  9. The Drain: With the physical STA balance depleted, the marginal price of all other assets in the pool relative to STA fell precipitously. The attacker executed cheap swaps to trade tiny fractions of STA for the pool’s entire supply of WETH, LINK, SNX, and WBTC, draining $500,000 in assets.

6. Pre-Deployment Security Checklist

Before deploying contracts that interact with arbitrary ERC-20 tokens, smart contract developers and security auditors must verify the following conditions:


7. Frequently Asked Questions

Can I use OpenZeppelin's SafeERC20 to prevent the fee on transfer token vulnerability?

No. OpenZeppelin's SafeERC20 library wrapper (which provides safeTransfer and safeTransferFrom) is designed to handle non-standard ERC-20 tokens that do not return a boolean value (e.g., USDT on Ethereum mainnet). It does not perform balance-difference checks and will not prevent accounting discrepancies caused by fee-on-transfer or rebasing tokens.

How does a negative rebase token affect a standard liquidity pool?

A negative rebase token reduces the balances of all holders globally. If a pool contract caches its asset holdings internally, a negative rebase leaves the pool holding fewer physical tokens on-chain than the sum of its internal balances. Early liquidity providers will be able to withdraw their original share value, while remaining liquidity providers will face failed withdrawals due to insolvencies.

Is the balance-difference pattern sufficient to support rebasing tokens?

No. The balance-difference pattern only secures the deposit step. It does not account for changes in the contract’s balance that occur after the deposit has finished. To fully support rebasing tokens, the contract must either use a share-based accounting system (similar to Lido's stETH share mechanism) or expose a public synchronization function that recalculates the internal pool valuation based on the current actual token balance of the contract.


Before deploying your next smart contract, verify its resistance against the fee on transfer token vulnerability. Use ContractScan to scan your code for integration bugs and protocol-level accounting errors.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →