Why fee-on-transfer tokens are dangerous

Many Solidity contracts are written under a simple assumption:

  • if you call transferFrom(user, address(this), 100 ether),
  • then the contract receives exactly 100 ether.

That assumption is false for fee-on-transfer tokens. A token may deduct 1%, 5%, or a dynamic fee, meaning the contract receives less than the requested amount. If your logic credits balances, mints shares, or updates internal accounting using the requested amount instead of the actual received amount, users can exploit the mismatch or simply trigger broken behavior.

Common affected systems include:

  • vault deposits
  • staking contracts
  • escrow and payment contracts
  • AMMs and liquidity routers
  • reward distribution systems
  • bridge adapters and treasury modules

The core problem is not the fee itself. The problem is accounting based on intent rather than observed state.


The failure mode: trusting the transfer amount

Consider a deposit function that records the nominal amount:

function deposit(uint256 amount) external {
    token.transferFrom(msg.sender, address(this), amount);
    balances[msg.sender] += amount;
}

If the token charges a 10% fee, the contract receives only 90 tokens but credits the user with 100. That creates an accounting deficit. Later withdrawals may fail, or the first depositor may extract value from later users.

The same issue appears in reverse:

function pay(address to, uint256 amount) external {
    token.transfer(to, amount);
    totalPaid += amount;
}

If the recipient receives less than amount, internal ledgers become inaccurate. If your contract uses those ledgers to enforce invariants, the mismatch can cascade into insolvency or denial of service.


The safe pattern: measure actual balance changes

The most reliable defense is to measure token balances before and after the transfer and use the delta as the true received amount.

Example: safe deposit accounting

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

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

contract FeeAwareVault {
    IERC20 public immutable token;
    mapping(address => uint256) public balances;

    constructor(IERC20 _token) {
        token = _token;
    }

    function deposit(uint256 amount) external {
        uint256 beforeBalance = token.balanceOf(address(this));

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

        uint256 afterBalance = token.balanceOf(address(this));
        uint256 received = afterBalance - beforeBalance;

        require(received > 0, "no tokens received");

        balances[msg.sender] += received;
    }
}

This pattern works because it relies on the contract’s actual token balance, not the token’s advertised behavior. If the token burns part of the transfer or redirects a fee elsewhere, the contract still credits only what it truly received.

Why this is better

  • It handles fixed and variable fees.
  • It avoids over-crediting deposits.
  • It works even when the token’s fee logic changes over time.
  • It makes the contract’s internal accounting match on-chain reality.

When balance-delta accounting is not enough

Measuring balances is necessary, but not always sufficient. Some tokens have unusual behavior:

  • rebasing tokens can change balances without transfers
  • tokens may charge fees on both transfer and transferFrom
  • some tokens reflect rewards or redistribute supply during transfers
  • malicious tokens may reenter during transfer hooks

If your protocol must support arbitrary ERC-20s, you need to define what “supported” means. In practice, many production systems restrict accepted tokens to a known allowlist and test them explicitly.

Support policy options

PolicyProsCons
Accept any ERC-20Easy integrationHard to secure; many edge cases
Accept only allowlisted tokensPredictable behaviorRequires governance or admin maintenance
Accept fee-on-transfer tokens with balance-delta accountingFlexible and saferStill vulnerable to rebasing or exotic token logic
Reject fee-on-transfer tokens entirelySimplest accountingLimits composability

For high-value systems, the safest choice is often to explicitly reject unsupported token types rather than trying to support every possible ERC-20 variant.


Handling deposits, shares, and pricing correctly

Fee-on-transfer bugs often appear in vaults and staking systems that mint shares based on deposit size. The correct share calculation should use the actual received amount.

Incorrect share minting

function deposit(uint256 amount) external {
    token.transferFrom(msg.sender, address(this), amount);
    uint256 shares = amount * totalShares / totalAssets;
    _mint(msg.sender, shares);
}

If the contract receives less than amount, the user gets too many shares.

Correct share minting

function deposit(uint256 amount) external {
    uint256 beforeBalance = token.balanceOf(address(this));
    require(token.transferFrom(msg.sender, address(this), amount), "transfer failed");
    uint256 received = token.balanceOf(address(this)) - beforeBalance;

    uint256 shares = totalShares == 0
        ? received
        : received * totalShares / totalAssets();

    _mint(msg.sender, shares);
}

This ensures share issuance reflects the real asset inflow.

Important design note

If your vault charges its own deposit fee, apply that fee to received, not to the requested amount. Otherwise, the user may be charged twice: once by the token and once by your contract.


Withdrawals: account for token-side fees too

Fee-on-transfer tokens can also affect withdrawals. If your contract sends amount to a user but the token deducts a fee, the user receives less than expected.

This is not always a contract bug, but it can still break user expectations and downstream integrations. For example:

  • a staking contract may promise “withdraw 100 tokens”
  • the user receives only 95
  • the UI or accounting system still shows 100 withdrawn

Best practice for withdrawals

  1. Document that the token may apply transfer fees.
  2. Base internal accounting on the amount the contract sends, not the user’s net receipt.
  3. If exact user receipt matters, do not support fee-on-transfer tokens.
  4. For critical systems, use tokens with predictable transfer semantics.

A contract cannot reliably force the recipient’s net amount when the token itself imposes a fee. That limitation should be treated as part of the token’s behavior, not hidden by the protocol.


Avoid assumptions in integrations and adapters

Protocols often wrap ERC-20 transfers inside routers, escrow contracts, or bridge adapters. These layers are especially vulnerable because they frequently chain multiple accounting steps.

Example of a fragile adapter

  • user deposits 100 tokens
  • adapter forwards 100 tokens to another contract
  • adapter records 100 as delivered
  • downstream contract receives only 97

Now both contracts disagree about the amount transferred.

Safer adapter pattern

  • measure the adapter’s balance before and after each hop
  • forward only the actual received amount
  • record each step independently
  • emit events with both nominal and actual values
event Deposited(address indexed user, uint256 requested, uint256 received);

function deposit(uint256 amount) external {
    uint256 beforeBalance = token.balanceOf(address(this));
    require(token.transferFrom(msg.sender, address(this), amount), "transfer failed");
    uint256 received = token.balanceOf(address(this)) - beforeBalance;

    emit Deposited(msg.sender, amount, received);
}

Including both values in events helps off-chain systems, indexers, and auditors understand the difference between user intent and on-chain reality.


Testing for fee-on-transfer behavior

You should not rely on mainnet assumptions alone. Add tests that simulate fee deduction and verify internal accounting.

What to test

  • deposit credits equal actual received amount
  • share minting uses received amount
  • withdrawal accounting remains consistent
  • events reflect nominal vs actual transfer values
  • unsupported tokens are rejected cleanly
  • repeated deposits do not accumulate rounding drift

Example test cases

ScenarioExpected result
100 token deposit with 2% feeContract balance increases by 98
Share minting after feeShares are based on 98, not 100
Withdrawal from fee tokenInternal accounting remains consistent
Zero-fee tokenBehavior matches standard ERC-20 assumptions
Unsupported rebasing tokenRevert or explicit rejection

If you use a local test token, implement a configurable fee so you can exercise edge cases deterministically.


Practical implementation guidelines

1. Use balance deltas for all inbound transfers

Never credit deposits using the requested amount unless the token is known to be standard and fee-free.

2. Separate nominal and actual values

In events, logs, and internal variables, distinguish:

  • requested: what the caller asked to transfer
  • received: what the contract actually got
  • sent: what the contract actually sent

This makes audits and incident response much easier.

3. Prefer allowlists for high-value systems

If your protocol manages user funds, support only tokens you have reviewed. Document whether fee-on-transfer tokens are accepted.

4. Be careful with rounding

When fees and share calculations both use integer division, small deposits may round to zero. Decide whether to:

  • reject dust deposits
  • accumulate them until they exceed a threshold
  • accept the rounding loss explicitly

5. Do not assume ERC-20 compliance implies uniform behavior

ERC-20 defines an interface, not economic semantics. A token can be ERC-20 compatible and still burn, tax, or redirect transfers.


A robust pattern for fee-aware deposits

Here is a more complete example that combines safe accounting with explicit event logging:

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

interface IERC20Minimal {
    function balanceOf(address account) external view returns (uint256);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract FeeAwarePool {
    IERC20Minimal public immutable asset;
    mapping(address => uint256) public deposits;
    uint256 public totalDeposits;

    event Deposited(address indexed user, uint256 requested, uint256 received);

    constructor(IERC20Minimal _asset) {
        asset = _asset;
    }

    function deposit(uint256 amount) external {
        uint256 beforeBalance = asset.balanceOf(address(this));
        require(asset.transferFrom(msg.sender, address(this), amount), "transfer failed");
        uint256 received = asset.balanceOf(address(this)) - beforeBalance;

        require(received > 0, "nothing received");

        deposits[msg.sender] += received;
        totalDeposits += received;

        emit Deposited(msg.sender, amount, received);
    }
}

This contract does not pretend the requested amount is the received amount. It records the truth and exposes it to users and off-chain systems.


Common mistakes to avoid

  • Crediting deposits with the nominal transfer amount
  • Minting shares before verifying the actual balance increase
  • Assuming transferFrom always moves the full amount
  • Ignoring fee behavior in event logs
  • Supporting arbitrary ERC-20s without testing
  • Mixing fee-on-transfer support with rebasing tokens without a clear policy
  • Using internal accounting that cannot tolerate small deltas or rounding

Summary

Fee-on-transfer tokens are not inherently unsafe, but they require contracts to treat token transfers as uncertain until verified. The safest approach is to measure actual balance changes and base all accounting on those deltas. For systems that need strict invariants, it is often better to support only a curated set of tokens or reject fee-on-transfer assets entirely.

If your contract handles deposits, shares, or cross-contract transfers, review every place where you assume requested == received. That single assumption is the root cause of many subtle and expensive bugs.

Learn more with useful resources