Fee-on-Transfer Tokens Break 3 of 6 Shadow Arena Audit Targets
How fee-on-transfer tokens silently break accounting in DeFi protocols. Pattern, real findings from Basin and Velodrome, and the one-line fix most teams miss.
TL;DR
- Fee-on-transfer (FoT) tokens take a percentage on every transfer. They are valid ERC-20 tokens that just don't behave the way most contracts assume ERC-20 tokens behave.
- Across the 6 audit targets in Zealynx Academy's Shadow Arena, 3 are documented as broken under FoT tokens: Basin (M-10), Velodrome (M-03), and every cToken-based fork in the catalog (Flux Finance, Canto v2, Venus) inherits the issue implicitly.
- The pattern is always the same. The protocol does
token.transferFrom(user, address(this), X), then assumesbalanceOf(this) += X. With an FoT token, the actual delta isX - fee. Every accounting calculation downstream is off by the fee percentage. - The fix is one rule, applied everywhere a transfer touches accounting: measure the delta, never trust the input amount.
balanceBefore, then transfer, thenbalanceAfter - balanceBefore. Use that delta for share math, collateral math, swap output, everything. - This bug class rarely shows up in CTFs because CTFs use clean ERC-20 mocks. Real production audits hit it constantly. If you want to train your eye for it, Shadow Arena is the right corpus.
Why this matters
When you audit a real DeFi protocol in 2026, you will encounter fee-on-transfer tokens. Not because they're rare. Because they're cheap to deploy and they're everywhere on long-tail listings. Some of them are deliberate (reflection tokens, deflationary tokens with built-in burns), some are unintentional (admin-controlled tax that gets enabled later), and some are hostile (an attacker integrating a malicious FoT token to drain a pool).
If your protocol takes arbitrary ERC-20 tokens as input, you have to handle this case. Otherwise you ship a contract that silently mis-accounts as soon as any user pairs it with the wrong token. The bugs don't crash. They don't revert. They just produce shares, collateral, and swap outputs that are slightly wrong, in the attacker's favor, on every interaction.
That "slightly wrong" compounds. By the time it's visible on-chain, the LP positions are imbalanced, the loan-to-value ratios are stale, and a sophisticated user has already noticed and started farming the gap.
The reason this class of bug clusters in audit findings is that it's invisible in standard testing. Most test suites use OpenZeppelin's reference ERC-20 implementation, which transfers exactly what you ask it to. The bug only fires when a real, fee-charging token shows up in production. That's why we treat it as a top-priority training pattern in Shadow Arena.
The mechanism
The ERC-20 standard says transfer and transferFrom return bool to indicate success. Nothing in the standard says the recipient receives exactly the amount specified by the caller. Fee-on-transfer tokens exploit that gap perfectly legally:
// Pseudocode of a typical FoT transfer
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
uint256 fee = amount * feeBasisPoints / 10_000;
uint256 actuallyDelivered = amount - fee;
_balances[from] -= amount;
_balances[to] += actuallyDelivered;
_balances[feeRecipient] += fee;
emit Transfer(from, to, actuallyDelivered);
emit Transfer(from, feeRecipient, fee);
return true;
}
That's it. The token reports success, and to ends up with less than amount. If the receiver naively assumes their balance went up by amount, every downstream computation breaks.
The naive pattern, used in countless protocols, looks like this:
// Naive: trusts the input amount
function depositLiquidity(IERC20 token, uint256 amount) external returns (uint256 shares) {
token.transferFrom(msg.sender, address(this), amount);
shares = (amount * totalSupply) / reserves[token];
reserves[token] += amount; // wrong: actual delta < amount
_mint(msg.sender, shares); // wrong: shares minted against phantom balance
}
After this transaction, reserves[token] is overstated by the FoT fee, and the user has been minted more shares than the pool's real assets justify. Now every subsequent depositor and every withdrawer is computing against an inflated reserve. Each interaction transfers a sliver of value from honest users to whoever shows up next.
The Shadow Arena findings
Zealynx Academy's Shadow Arena documents this pattern across multiple targets. Three named instances:
Basin M-10
Basin is a composable AMM (Beanstalk Farms' Wells/Pumps/Aquifers system, 1,145 SLOC of Solidity in Shadow Arena). Its M-10 finding is a classic FoT pattern in the Well's deposit path. The pool's reserves diverge from balanceOf the moment an FoT token is deposited, and the divergence persists across every subsequent swap because the pool computes output amounts against stored reserves, not actual balances. The fix in this case is sync()-style accounting at the entry boundary, plus delta-measurement on each transfer.
Velodrome M-03
Velodrome is a Solidly fork running on Optimism (1,914 SLOC in Shadow Arena, 13 documented findings). Its M-03 covers the bribe-deposit path. Bribes are pooled and distributed to voters proportionally to their veNFT weight. With FoT bribes, the pool gets less than the bribe sender said they sent, but the distribution math still divides the originally-claimed amount across voters. Result: voters claim more than the contract actually holds, the last claimer reverts on a transfer the contract can't fulfill, and the rest of the time the bribe is silently under-paid.
cToken donation paths (Flux Finance, Canto v2, Venus)
The three Compound V2 forks in Shadow Arena (Flux Finance, Canto v2, Venus) all inherit Compound's exchange-rate math. That math is cash / supply, where cash is underlying.balanceOf(address(this)) plus the cToken's tracked totals. With FoT underlying tokens, deposits update supply based on the input amount, but cash reflects the post-fee balance. The exchange rate drifts, and depositors who arrive after FoT-token mints find their share calculation was already skewed before they entered.
In Compound's original markets the issue rarely fires because the listed underlyings (DAI, USDC, ETH) don't charge transfer fees. But in forks that list arbitrary tokens (Flux's CASH, Canto's cNote, Venus's expanded asset list), the assumption breaks the moment a user-listable FoT token arrives.
Why this is under-detected
Three reasons FoT bugs slip through normal review:
- Test suites use clean tokens. OpenZeppelin's
ERC20.soldoesn't charge fees. Most test fixtures use it directly. The bug never manifests until production with a real FoT token. - Manual auditors look for the pattern at the entry point but miss downstream propagation. Auditors who know the FoT pattern often check the
transferFromcall site for delta-measurement, but miss that the same logic also needs to apply ontransferoutflows, on rebase events, and on cross-protocol settlement paths. - Static analyzers can't reason about token semantics. Slither, Mythril, and similar tools treat all ERC-20s as identical. The bug requires knowing which tokens charge fees, which is runtime information.
That's exactly why the Shadow Arena training set works. It forces auditors to reason about real token behavior across a non-trivial codebase, instead of pattern-matching against synthetic vulnerabilities.
The fix, applied everywhere
There is one rule. Anywhere a token transfer touches accounting, measure the delta:
// Correct: measures the actual amount received
function depositLiquidity(IERC20 token, uint256 amount) external returns (uint256 shares) {
uint256 balanceBefore = token.balanceOf(address(this));
token.transferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - balanceBefore;
shares = (received * totalSupply) / reserves[token];
reserves[token] += received;
_mint(msg.sender, shares);
}
Three things to notice. The contract no longer trusts the input amount for accounting. The reserve update uses the measured delta. The share calculation also uses the measured delta. If the token charges a fee, the user gets fewer shares (correctly), and the reserve grows by the actual cash deposited (correctly).
The same pattern applies on outflow:
// Correct on the way out: also measures the delta
function withdrawLiquidity(IERC20 token, uint256 shares) external returns (uint256 sentToUser) {
uint256 amount = (shares * reserves[token]) / totalSupply;
uint256 balanceBefore = token.balanceOf(msg.sender);
token.transfer(msg.sender, amount);
sentToUser = token.balanceOf(msg.sender) - balanceBefore;
reserves[token] -= amount; // contract loses `amount`, user gains `sentToUser`
_burn(msg.sender, shares);
}
For protocols that don't want to support FoT tokens at all, the cleaner option is to reject them at the entry boundary: enforce that received == amount, and revert otherwise. That requires every user to use a non-FoT token, which is a reasonable design choice if the protocol's risk model can't tolerate the complexity.
Related questions
What is a fee-on-transfer (FoT) token? An ERC-20 token where every transfer deducts a percentage as a fee, so the recipient receives less than the sender specified. Common in deflationary tokens and "reflection" tokens.
Are FoT tokens compliant with the ERC-20 standard? Yes. The ERC-20 spec only requires transfer and transferFrom to return bool and emit Transfer. It does not require the recipient to receive exactly amount. FoT tokens are technically conforming.
Does the bug only matter for AMMs? No. Any protocol that pulls tokens via transferFrom and then computes downstream state based on the input amount is exposed. Lending, vaults, bridges, NFT marketplaces with token settlement, all share the pattern.
Why don't more contracts use safeTransferFrom? OpenZeppelin's SafeERC20.safeTransferFrom only checks the return value. It does not verify that balanceOf increased by amount. The fix is delta-measurement, not safe transfer.
How is this different from a rebasing-token bug? Rebasing tokens change balanceOf independently of transfers. FoT tokens reduce balanceOf predictably during transfers. The detection pattern is the same (measure deltas), but rebasing also requires periodic rebalancing logic that FoT does not.
Where to find this in production
If you want to see the pattern up close, every cToken-based fork in Shadow Arena reproduces it. Start with Flux Finance, which has the smallest surface area (4,365 SLOC) and the clearest documentation. The Compound V2 fork inherits the pattern from upstream, so understanding it once gives you eyes on Canto v2 and Venus by extension. Basin and Velodrome give you the AMM and reward-distribution variants.
For broader context, the same class of bug has caused real production losses. Hundred Finance lost $7M to a related cToken donation attack in 2023 that exploited the same cash / supply math FoT exposes. Studying these patterns before you ship a fork is cheaper than discovering them in production.
Tagged