← Back to Blog

Fee-on-Transfer Token Security: Deflationary, Rebase, and Non-Standard ERC-20 Vulnerabilities

2026-04-18 erc-20 fee on transfer deflationary token rebase token solidity security 2026

Compound and Aave both maintain explicit token whitelists. One reason: fee-on-transfer and rebase tokens are incompatible with their accounting models, and listing one would open an immediate exploit path. These protocols made a deliberate engineering decision to exclude an entire class of ERC-20 tokens. Many smaller protocols have not made that decision consciously — which is why fee-on-transfer vulnerabilities appear in audit report after audit report.

This post covers the five most common vulnerability patterns that arise from non-standard ERC-20 tokens: the accounting desync from fee-on-transfer, rebase-driven balance drift, the AMM internal accounting trap, slippage failures in DEX integrations, and the approval exhaustion bug.


What Non-Standard ERC-20 Tokens Are

The ERC-20 standard says transfer(to, amount) moves amount tokens from the caller to to. That sounds unambiguous. In practice, three classes of tokens violate this expectation in ways that break protocol integrations:

Fee-on-transfer (deflationary) tokens deduct a percentage during every transfer. The sender authorizes amount, the recipient receives amount - fee, and the fee goes to a burn address or fee wallet. SAFEMOON popularized this model; hundreds of imitations followed. The key property is that the transfer amount the caller specifies is not the amount the recipient receives.

// Example fee-on-transfer token implementation
contract DeflationaryToken is ERC20 {
    uint256 public constant FEE_BPS = 200; // 2% fee
    address public feeWallet;

    function _transfer(address from, address to, uint256 amount) internal override {
        uint256 fee = (amount * FEE_BPS) / 10000;
        super._transfer(from, feeWallet, fee);
        super._transfer(from, to, amount - fee);
        // Caller specified 'amount', recipient got 'amount - fee'
    }
}

Deflationary burn tokens are a variant where the fee is sent to address(0) rather than a wallet, permanently reducing total supply on every transfer. The effect on protocol accounting is identical.

Rebase (elastic supply) tokens change all holder balances simultaneously through a global multiplier without firing individual Transfer events. Ampleforth (AMPL) is the canonical example: once per day, the protocol adjusts every holder's balance up or down to target a price peg. Lido's stETH accrues staking rewards by increasing holder balances daily. A contract holding 1000 stETH today may find it holds 1001 stETH tomorrow — with no transfer, no event, and no explicit action on its part.

The problems below apply to any protocol that integrates these tokens without accounting for their non-standard behavior.


Vulnerability 1: Assuming Transfer Amount Equals Received Amount

The naive pattern for accepting token deposits records the amount parameter directly:

// VULNERABLE: assumes no-fee transfer
contract NaiveVault {
    mapping(address => mapping(address => uint256)) public deposits;

    function deposit(address token, uint256 amount) external {
        IERC20(token).transferFrom(msg.sender, address(this), amount);
        // Records 'amount' — but if token has a 2% fee,
        // the vault only received amount * 0.98
        deposits[msg.sender][token] += amount;
    }

    function withdraw(address token, uint256 amount) external {
        require(deposits[msg.sender][token] >= amount, "insufficient");
        deposits[msg.sender][token] -= amount;
        // Tries to send 'amount', but vault only holds amount * 0.98
        // The second depositor's funds subsidize the first withdrawal
        IERC20(token).transfer(msg.sender, amount);
    }
}

The exploit is straightforward: a user deposits a fee-on-transfer token, the vault records more than it received, and subsequent withdrawals drain funds belonging to other depositors. With a 2% fee token and two depositors of equal size, the first withdrawal succeeds, the second fails entirely.

The fix is the balanceBefore/balanceAfter pattern — measure what actually arrived rather than trusting the amount parameter:

// SECURE: balance-delta deposit accounting
contract SafeVault {
    mapping(address => mapping(address => uint256)) public deposits;

    function deposit(address token, uint256 amount) external {
        uint256 balanceBefore = IERC20(token).balanceOf(address(this));
        IERC20(token).transferFrom(msg.sender, address(this), amount);
        uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore;
        // Record what was actually received, not what was requested
        deposits[msg.sender][token] += received;
    }

    function withdraw(address token, uint256 amount) external {
        require(deposits[msg.sender][token] >= amount, "insufficient");
        deposits[msg.sender][token] -= amount;
        uint256 balanceBefore = IERC20(token).balanceOf(address(this));
        IERC20(token).transfer(msg.sender, amount);
        uint256 sent = balanceBefore - IERC20(token).balanceOf(address(this));
        // If sent < amount (fee on withdrawal too), accounting is still consistent
        _ = sent; // used for illustration
    }
}

Note that balanceBefore/balanceAfter must wrap the actual transfer call. Placing it anywhere else — before transferFrom returns, or after other state changes — opens a reentrancy window where a malicious token could re-enter and manipulate the balance reading.


Vulnerability 2: Protocol Accounting Desync with Rebase Tokens

Rebase tokens introduce a different failure mode: the balance changes without any protocol action. A lending protocol that accepts stETH as collateral and caches its collateral value will find that its internal accounting diverges from reality after every daily rebase.

// VULNERABLE: cached balance drifts from actual balance with rebase tokens
contract BrokenLendingPool {
    mapping(address => uint256) public collateralBalance; // cached at deposit time

    function depositCollateral(address token, uint256 amount) external {
        IERC20(token).transferFrom(msg.sender, address(this), amount);
        collateralBalance[msg.sender] += amount;
        // stETH balance grows daily via rebase
        // collateralBalance[msg.sender] becomes stale immediately
    }

    function getCollateralValue(address user, address token) external view returns (uint256) {
        // Returns stale cached value, not actual balance
        return collateralBalance[user];
    }
}

For stETH, the desync is mild and directionally favorable to the depositor (accrued rewards are invisible to the protocol). For AMPL during a negative rebase, the desync is dangerous: a borrower's cached collateral value stays high while the actual balance shrinks, leaving the position under-collateralized without triggering liquidation.

The correct approaches are:

  1. Use live balanceOf for all collateral valuations — never cache a balance that can change externally.
  2. Use wrapped non-rebasing versions — wstETH wraps stETH in a share-based model where the balance does not change; only the share price does. This is the approach Aave v3 takes.
  3. Explicitly exclude rebase tokens — document the restriction in the protocol and enforce it in the asset listing process.
// SECURE: read live balance for rebase-compatible accounting
function getCollateralValue(address user, address token) external view returns (uint256) {
    // Reads current on-chain balance, which reflects any rebase
    return IERC20(token).balanceOf(address(this));
    // Note: for multi-user vaults, this requires share-based accounting
    // (user shares / total shares) * totalAssets()
}

Vulnerability 3: Liquidity Pool Internal Accounting vs. balanceOf

AMM designs split into two categories with respect to rebase token safety.

An AMM that computes reserves by calling token.balanceOf(address(this)) at the time of each swap is inherently rebase-safe: after a rebase, the next interaction reads the updated balance and the pool price adjusts accordingly. Uniswap v2 falls into this category because getReserves() returns cached reserves, but sync() and the swap functions update reserves from balanceOf before computing outputs.

An AMM that maintains strictly internal accounting — tracking inflows and outflows through its own state rather than calling balanceOf — will accumulate a permanent desync:

// VULNERABLE: internal-accounting AMM breaks with rebase tokens
contract InternalAccountingAMM {
    uint256 public reserve0;
    uint256 public reserve1;

    function swap(uint256 amountIn, bool zeroForOne) external {
        // Uses internal reserves, never reads balanceOf
        uint256 amountOut = getAmountOut(amountIn, reserve0, reserve1);
        if (zeroForOne) {
            reserve0 += amountIn;
            reserve1 -= amountOut;
        } else {
            reserve1 += amountIn;
            reserve0 -= amountOut;
        }
        // After a negative AMPL rebase, actual balanceOf < reserve0
        // LP tokens can no longer be redeemed at face value
    }
}

When a negative rebase occurs, the pool's internal reserve0 exceeds the actual token balance. LPs who withdraw early receive their full share; LPs who withdraw later receive less than their recorded share. This is effectively a slow bank run triggered by an external accounting event the AMM does not observe.

The fix for AMMs that want rebase support is to call sync() — or an equivalent that reads from balanceOf — before any operation that depends on reserve accuracy.


Vulnerability 4: Fee-on-Transfer Breaking Slippage in DEX Integrations

DEX router contracts often accept a user-specified amountIn and compute expected output based on that amount. When the token being swapped has a transfer fee, the pool receives less than amountIn, which produces less output than the router calculated — often below the user's amountOutMin slippage limit, causing the transaction to revert.

// Problematic DEX integration with fee-on-transfer tokens
interface IRouter {
    function swapExactTokensForTokens(
        uint256 amountIn,          // user sends this from their wallet
        uint256 amountOutMin,      // calculated based on amountIn
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}

// User calls:
// router.swapExactTokensForTokens(1000e18, 970e18, [FOT_TOKEN, USDC], ...)
//
// User's wallet: 1000 FOT_TOKEN debited
// Router receives: 990 FOT_TOKEN (1% fee taken)
// Pool receives: 980.1 FOT_TOKEN (1% fee taken again on router→pool transfer)
// Output: ~950 USDC (based on 980.1 input, not 1000)
// amountOutMin check: 950 < 970 → REVERT

Uniswap v2 added swapExactTokensForTokensSupportingFeeOnTransferTokens specifically to handle this case. That variant does not check output against a pre-computed amount; instead it measures the recipient's balance delta after the swap completes.

// SECURE: use the fee-on-transfer variant
router.swapExactTokensForTokensSupportingFeeOnTransferTokens(
    amountIn,
    amountOutMin, // set conservatively, accounting for fee
    path,
    to,
    deadline
);

Protocols building custom swap logic must apply the balanceBefore/balanceAfter pattern at every token hop in the swap path, not just at the entry point.


Vulnerability 5: Approval for Exact Amount with Transfer Fee

The ERC-20 approve/transferFrom flow has an underappreciated interaction with fee-on-transfer tokens. When a spender is approved for an exact amount and calls transferFrom, the full approved amount is consumed from the allowance — but the recipient receives only amount - fee.

// Sequence that leaves users short-changed:
// 1. User approves router for exactly 1000 tokens
token.approve(router, 1000e18);

// 2. Router calls transferFrom — deducts full 1000 from allowance
IERC20(token).transferFrom(user, pool, 1000e18);
// allowance[user][router] is now 0

// 3. Pool received 980 tokens (2% fee taken)
// 4. Router computed output based on 1000 tokens
// 5. User received less output than expected AND allowance is exhausted
// 6. Next operation requiring a transfer will fail — user must re-approve

This creates two distinct problems: the user receives less than the router quoted (a silent slippage loss), and any multi-step protocol flow that relies on a partially consumed allowance may fail mid-execution because the allowance ran out faster than expected.

For protocol developers, the mitigation is to document clearly that exact-amount approvals do not work with fee-on-transfer tokens, and to instruct users to approve a buffer amount or use type(uint256).max. More robust is to measure actual received amounts at each step and recompute outputs based on reality rather than the approved figure.


Vulnerable vs. Secure Deposit Pattern: Side-by-Side

// VULNERABLE
function deposit(address token, uint256 amount) external {
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    userBalance[msg.sender] += amount; // overcounts if fee-on-transfer
}

// SECURE
function deposit(address token, uint256 amount) external {
    uint256 before = IERC20(token).balanceOf(address(this));
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    uint256 received = IERC20(token).balanceOf(address(this)) - before;
    userBalance[msg.sender] += received; // records actual received amount
}

The pattern is not complex. The challenge is applying it consistently across every transfer in a codebase: deposits, liquidation repayments, fee collections, reward distributions. Any single call-site that omits the balance check while others use it creates an inconsistency that can be exploited.


How to Detect Non-Standard Tokens

SafeERC20.safeTransfer from OpenZeppelin handles the missing return value problem — it uses a low-level call and checks whether the call succeeded and whether a returned bool (if any) is true. It does not handle fee-on-transfer or rebase behavior. safeTransfer with a fee-on-transfer token will succeed and return no error; the fee is simply silently deducted.

The only reliable detection approach is testing with the actual token in a fork test environment:

// Foundry fork test: verify protocol behavior with real fee-on-transfer token
function testFeeOnTransferDeposit() public {
    // Fork mainnet, use a known fee-on-transfer token
    address FOT_TOKEN = 0x...; // e.g., a known 1% fee token
    deal(FOT_TOKEN, address(this), 1000e18);

    IERC20(FOT_TOKEN).approve(address(vault), 1000e18);
    vault.deposit(FOT_TOKEN, 1000e18);

    // If vault recorded 1000 but received 990, this assertion fails
    assertEq(vault.userBalance(address(this)), 990e18,
        "Vault must record received amount, not transfer amount");
}

Static analysis tools cannot detect fee-on-transfer accounting bugs in the general case — the bug requires understanding that a specific token type will behave non-standardly, and that the protocol's accounting model assumes standard behavior. This is a semantic, context-dependent issue.

For rebase tokens, integration tests should simulate a rebase event and verify the protocol's collateral valuations remain accurate afterward:

function testRebaseDesync() public {
    // Deposit stETH
    vault.depositCollateral(address(stETH), 1000e18);

    // Simulate daily rebase (stETH balance grows)
    vm.prank(stETH.rebaseOracle());
    stETH.rebase(1.001e18); // 0.1% daily yield

    // Protocol must reflect updated balance in collateral value
    uint256 collateralValue = vault.getCollateralValue(address(this), address(stETH));
    assertGt(collateralValue, 1000e18, "Collateral value must update after rebase");
}

What ContractScan Detects

Vulnerability Pattern Detected
Fee-on-transfer accounting bug transferFrom followed by direct amount accounting without balanceBefore/after
Missing balance-delta check on deposits Deposit functions that record amount parameter directly
Rebase token balance caching Storage writes of balanceOf return values used later as collateral values
Unchecked slippage with fee tokens amountOutMin computed from amountIn without fee adjustment
Allowance exhaustion in multi-step flows approve(spender, exactAmount) patterns in fee-token-compatible routers
Missing safeTransfer usage transfer/transferFrom calls not wrapped in SafeERC20
AMM reserve sync gaps Internal reserve accounting not reconciled with balanceOf

Test your ERC-20 integration at https://contract-scanner.raccoonworld.xyz — the AI engine checks for fee-on-transfer accounting patterns, rebase token compatibility, and missing balance-delta guards across your entire codebase in a single scan.


Related: ERC-20 Token Security Vulnerabilities in Solidity — the full spectrum of ERC-20 risks including missing return values, uncapped mint functions, and blacklist rug vectors.

Related: Token Approval Security: The Infinite Allowance Problem — EIP-2612 permit(), allowance race conditions, and the approve/transferFrom attack surface.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

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