Fee-on-Transfer Tokens: The ERC-20 Tax That Breaks Protocols
Your protocol assumes `transferFrom(user, address(this), 100)` deposits exactly 100 tokens. Fee-on-transfer ERC-20s break that assumption and silently corrupt every accounting invariant downstream. The fix is one line — measure balance deltas, not transfer arguments — but the bug has drained…
The Assumption That Breaks Everything
Ninety-nine percent of Solidity tutorials teach you to deposit tokens like this:
```solidity
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
```
Looks fine. Compiles fine. Passes your unit tests against `MockERC20`. Ships to mainnet. Then someone deposits SAFEMOON, or a reflection token, or — eventually — a USDT that finally flips the dormant fee switch in its contract, and your accounting silently diverges from reality.
The vulnerability is simple: **fee-on-transfer (FoT) tokens deduct a tax inside `transfer`/`transferFrom`, so the recipient receives less than `amount`.** Your protocol credited the user with `amount`. The contract holds `amount - fee`. Multiply across deposits, and the last withdrawer eats the loss.
What Fee-on-Transfer Tokens Actually Do
A typical FoT implementation overrides `_transfer`:
```solidity
function _transfer(address from, address to, uint256 amount) internal override {
uint256 fee = (amount * feeBps) / 10_000;
super._transfer(from, address(this), fee); // burn or treasury
super._transfer(from, to, amount - fee);
}
```
Variants include reflection tokens (PAXG, SAFEMOON-likes), deflationary burn-on-transfer tokens, and rebasing tokens like AMPL where balances change between blocks without any transfer at all. Critically, **USDT's contract has a dormant `basisPointsRate` fee parameter that Tether can enable at any time.** It's been zero for years. It is not guaranteed to stay zero.
The ERC-20 spec never required `transferFrom(from, to, x)` to move exactly `x`. We just all pretended it did.
The Fix: Trust Balances, Not Arguments
Measure what actually arrived:
```solidity
function deposit(uint256 amount) external {
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore;
balances[msg.sender] += received;
totalDeposits += received;
}
```
Three rules:
1. **Always use `SafeERC20.safeTransferFrom`** — non-standard tokens (USDT again) don't return a bool. Without SafeERC20 the call silently fails the return-data check.
2. **Credit `received`, not `amount`** — even if today's token charges no fee, tomorrow's fork might.
3. **Re-check on the way out too.** If your withdrawal logic transfers `userBalance` to the user, the user receives `userBalance - fee`. Either accept that asymmetry explicitly or revert on FoT tokens entirely.
If your protocol's invariants cannot tolerate FoT tokens — for example, an AMM where `k = x*y` is sacred — gate them at listing time:
```solidity
function _assertNoFeeOnTransfer(address token) internal {
uint256 before = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(msg.sender, address(this), 1e6);
require(IERC20(token).balanceOf(address(this)) - before == 1e6, "FoT not supported");
}
```
Uniswap V2 famously did not do this and shipped explicit `swapExactTokensForTokensSupportingFeeOnTransferTokens` functions instead. Their approach: support FoT in swaps, but the pair contract's reserves are still synced via `balanceOf`, so the invariant holds against the actual balance.
Real-World Damage
The pattern shows up in lending protocols repeatedly. **Compound V2 forks**, **Aave V2 forks**, and dozens of yield aggregators have shipped with the `balances[user] += amount` bug. The exploit isn't always a hack in the dramatic sense — it's slow accounting drift that surfaces when the pool is drained and someone discovers they cannot withdraw their full credited balance.
**Balancer V1 (2020)** lost roughly **$500K** when an attacker used STA (a deflationary token) in a flash-loan loop. Each swap deducted 1% from the pool's STA balance while Balancer's internal accounting did not. The attacker drained STA from the pool to dust, then drained WETH, WBTC, LINK, and SNX against the broken invariant.
- **Multiple Compound forks on BSC** had to halt FoT collateral types after users discovered they could borrow against inflated deposit balances.
- **Pickle Finance, Harvest, and several yield vaults** patched FoT handling in 2021 after audit findings showed share-price calculations could be gamed with deflationary tokens.
The Balancer incident is the canonical case study — read the post-mortem and you'll see the same `transferFrom(amount)` assumption sitting at the root.
Rebasing Tokens Are a Different Beast
FoT changes balances *during* transfer. Rebasing tokens (AMPL, stETH before wrapping) change balances *between* transfers, with no event your contract observes. If you snapshot a user's deposit and later compare `balanceOf(this)` to that snapshot, a positive rebase makes it look like someone deposited free money; a negative rebase makes withdrawals revert.
For rebasing tokens, the answer is usually: **don't store raw balances at all.** Store shares of a pool, like Yearn vaults or wstETH. Or refuse to support them.
Audit Checklist
Every `transferFrom` call into your contract should answer:
Am I using SafeERC20?
- Am I crediting the user with the arguments or with the actual balance delta?
- Does any downstream invariant (total supply of shares, AMM k, debt accounting) assume the delta equals the argument?
- If a user-supplied token is added later (permissionless listings, factory pools), can it be FoT or rebasing?
- On withdrawals, does the user receiving less than expected break a `require` somewhere?
If you're not sure your codebase passes all five, run it through our [free AI audit](https://www.cryptohawking.com/audit) — FoT-mishandling is one of the patterns it flags automatically. For protocols sitting on real TVL with permissionless token listings, the [manual audit](https://www.cryptohawking.com/audit/manual) is $5,000, three business days, and we read every `transferFrom` call by hand.
The Takeaway
ERC-20 is a suggestion, not a contract. `transferFrom(from, to, amount)` is a function that *might* move `amount` tokens. Build your accounting around what your contract actually receives, and the FoT class of bugs disappears. Forget to, and you've handed an attacker a slow leak waiting for a flash loan.
FAQ
Does USDT charge a fee on transfer?
Currently, no — USDT's `basisPointsRate` is set to zero. But the fee mechanism is built into the deployed contract and Tether has the ability to enable it via an owner-only function. The maximum fee is capped (originally at 20 basis points, with a per-transfer cap), but any non-zero rate breaks protocols that assume `transferFrom(amount)` moves exactly `amount`. Treat USDT as a latent FoT token: use balance-delta accounting and SafeERC20 even though the fee is currently inactive. This is one of the cheapest forms of future-proofing you can do.
Why not just blocklist fee-on-transfer tokens?
You can, and for some protocols it's the right call — AMMs with strict invariants, lending markets with isolated risk, anywhere a 1 wei discrepancy can be exploited. Use a one-time probe transfer in your listing function to detect FoT. But blocklisting doesn't help if the token *becomes* FoT later (USDT, upgradeable tokens via proxy, governance-controlled fee switches). The robust answer is to handle FoT correctly everywhere via balance-delta accounting, and only blocklist when your math fundamentally cannot tolerate it.
How do I handle fee-on-transfer on the withdrawal side?
When the user withdraws, your contract calls `token.transfer(user, amount)` and the user receives `amount - fee`. Two reasonable strategies: (1) document this asymmetry — the user pays the fee, accounting stays clean; (2) measure the user's balance delta and require they receive at least a minimum, reverting otherwise. Never try to gross-up the transfer to make the user whole; the protocol eats the fee and other users subsidize it. Most lending protocols and vaults choose option 1 explicitly in their docs.
Does SafeERC20 protect against fee-on-transfer bugs?
No. SafeERC20 from OpenZeppelin solves a different problem: it handles non-standard ERC-20s that don't return a bool (like USDT and BNB) and reverts on failed transfers. It does *not* check whether the recipient received the requested amount. You still need to measure `balanceOf` before and after the call. Combine both: `safeTransferFrom` for the call itself, balance-delta accounting for the credited amount. They're complementary defenses against different categories of ERC-20 weirdness.
Are rebasing tokens the same vulnerability as fee-on-transfer?
Related but distinct. FoT tokens change balances during transfer; rebasing tokens (AMPL, stETH) change balances independently of transfers, triggered by oracle updates or time. Balance-delta accounting fixes FoT but not rebasing — a user's recorded balance can drift without any contract interaction. The standard solution is shares-based accounting: store the user's proportional claim on the pool rather than a raw token amount, the way Yearn vaults and wstETH work. Or wrap the rebasing token into a non-rebasing version at the boundary.