Web3 & smart contracts
Verified by ROOTVIEL13 minToken Semantics: Fee-on-Transfer, Rebasing & Return Values
Protocols that trust the amount they requested to transfer — instead of measuring the balance actually received — and that assume every ERC-20 behaves canonically, break against fee-on-transfer and rebasing tokens, non-standard return values, and the approve/transferFrom race.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
- Evidence scope
- Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
- Analysis family
- RootRaven
- CWE
- CWE-682, CWE-840, CWE-362
- OWASP
- SC06
Definition
This class is a family of accounting and interface-assumption defects that arise because "ERC-20" is a loose standard with many non-conforming implementations. Four recurring faults: requested-vs-received mismatch — a protocol credits a user the amount it asked to transfer, but a fee-on-transfer token delivers less, so the protocol’s books overstate its holdings; rebasing drift — a token whose balances change out-of-band (elastic supply) invalidates any amount a protocol cached; non-standard return values — tokens that return nothing, or return false instead of reverting, break code that assumes a boolean or ignores the result; and the approve/transferFrom race — the well-known allowance front-running pattern where changing a non-zero allowance can be exploited by a spender who front-runs the change.
The common error is treating the token as a trusted, uniform counterparty whose transfer moves exactly the requested amount and signals success canonically. Many deployed tokens violate one or more of those assumptions.
Context
Any protocol that custodies or routes arbitrary ERC-20 tokens is exposed: vaults, lending markets, AMMs, staking contracts, payment splitters. The risk is greatest where the token set is permissionless (anyone can list a token) because the protocol cannot assume canonical behaviour. These are not bugs in the token per se — fee-on-transfer and rebasing are intentional designs — but in the protocol’s assumptions about tokens.
- ▸Vaults/AMMs that compute shares or reserves from a requested deposit amount.
- ▸Lending markets crediting collateral by the amount passed to transferFrom.
- ▸Routers that chain transfers assuming each moves the full amount.
- ▸Any code calling transfer/transferFrom/approve without handling non-standard returns or the allowance race.
Technical fundamentals
The ERC-20 interface specifies transfer/transferFrom/approve returning a bool, but real tokens diverge: some return nothing (pre-standard tokens), some return false on failure instead of reverting, some take a fee on every transfer (recipient receives less than sent), and some rebase (balanceOf changes without a transfer the protocol observed). The robust accounting rule is balance-delta measurement: record balanceOf(address(this)) before and after the transfer and credit the difference — the amount actually received — rather than the amount requested. For return values, the robust rule is to use a safe wrapper (OpenZeppelin SafeERC20) that treats a missing return as success and a false return as failure, reverting on anything non-conforming.
The approve/transferFrom race is a separate, interface-level hazard: approve sets an absolute allowance. If an owner changes a non-zero allowance from X to Y, a spender watching the mempool can spend the old X before the change lands and the new Y after, spending X + Y. Mitigations are setting allowance to zero before setting a new value, or using increaseAllowance/decreaseAllowance (relative adjustments). EIP-2612 permit() does not by itself remove the race, but moves approval off-chain with a nonce.
- Balance-delta accounting
- Credit the measured difference in balanceOf before and after a transfer — the received amount — not the requested amount.
- Fee-on-transfer token
- A token that deducts a fee so the recipient receives less than the sender sent.
- Rebasing token
- A token whose balances change out-of-band (elastic supply), invalidating any cached amount.
- Approval race
- Changing a non-zero allowance lets a spender front-run to spend both the old and the new value.
Root cause
The root cause is an unjustified assumption that the token behaves canonically: that the requested amount equals the received amount, that balances are stable between observations, and that transfer/approve signal success via a reverting boolean. Each assumption maps to a failure: requested ≠ received for fee-on-transfer; stable ≠ true for rebasing; and ignored/assumed return values for non-standard tokens. The approval race is a distinct root cause — the absolute-allowance semantics combined with mempool visibility.
It is a specification/assumption defect: the protocol is correct for a canonical ERC-20 but incorrect for the broader population of tokens it actually accepts.
How the attack works, step by step
The fee-on-transfer inflation path:
- 1A vault credits shares equal to the deposit amount it passed to transferFrom.
- 2The depositor uses a fee-on-transfer token; the vault requests 100 but actually receives 98.
- 3The vault credits shares for 100 though it holds only 98 — its books now overstate assets.
- 4Repeated across depositors, the shortfall accumulates; the last withdrawers cannot be made whole.
- 5An attacker can also exploit the gap directly where the inflated credit is immediately redeemable against other assets.
The non-standard-return path: a protocol calls transfer, the token returns false (or nothing) on failure but the protocol does not check, so it proceeds as though the transfer succeeded — losing track of funds. The approval-race path: an owner lowers an allowance from X to Y; the spender front-runs with a transferFrom of X, then after the change spends Y, taking X + Y.
Vulnerable example
A synthetic, illustrative vault that credits the requested amount, ignores the transfer return value, and leaves allowance changes racey:
synthetic — do not use; illustrates the flaw// SPDX-License-Identifier: MIT // UNSAFE: trusts requested amount, ignores return value. pragma solidity ^0.8.20; interface IERC20Loose { function transferFrom(address f, address t, uint256 a) external returns (bool); function transfer(address t, uint256 a) external returns (bool); function balanceOf(address a) external view returns (uint256); } contract NaiveVault { IERC20Loose public token; mapping(address => uint256) public shares; function deposit(uint256 amount) external { // BUG 1: return value ignored (token may return false / nothing). token.transferFrom(msg.sender, address(this), amount); // BUG 2: credits the REQUESTED amount, not the amount received. shares[msg.sender] += amount; // fee-on-transfer => over-credit } function withdraw(uint256 amount) external { shares[msg.sender] -= amount; token.transfer(msg.sender, amount); // BUG 1 again; also rebasing-unaware } }
With a fee-on-transfer token the vault credits more shares than it holds backing for; with a token that returns false instead of reverting, a failed transfer still credits shares; with a rebasing token the cached share math drifts from real balances.
Impact
- ▸Insolvency: over-credited shares/collateral exceed real holdings; later users cannot withdraw in full.
- ▸Fund loss: ignored false/empty returns let the protocol act on transfers that did not move value.
- ▸Accounting drift: rebasing supply changes desynchronize cached amounts from actual balances.
- ▸Theft via allowance race: a spender front-runs an allowance change to spend both old and new values.
Detection
Static analysis flags: credits/accounting derived from the requested amount rather than a measured balance delta; raw transfer/transferFrom/approve calls whose return value is unchecked (not wrapped in SafeERC20); and direct approve(x) from a non-zero allowance without a zero-reset or relative adjustment. These are precondition findings against the canonical-token assumption. Because whether a given deployed token is fee-on-transfer or rebasing is a runtime property, static analysis alone cannot confirm exploitation.
Dynamic detection on a local fork deploys or configures a fee-on-transfer/rebasing/false-returning mock token, runs the protocol’s deposit/withdraw, and checks whether credited shares exceed received balance, or whether a failed transfer still credited. An observed over-credit is a reproduced finding. Differentiate a static assumption finding from a lab-reproduced insolvency.
How autonomous agents investigate it
An autonomous agent models the protocol’s token interactions as an accounting contract: for each transfer, does the credited amount come from a measured balance delta or from a requested figure? Is each transfer/approve return value checked? Are allowance changes race-safe? It then considers the token population the protocol admits — permissionless listing widens the set to include fee-on-transfer and rebasing tokens.
A requested-amount credit or unchecked return is a SIGNAL. The agent composes a concrete scenario — deposit of a fee-on-transfer mock, a false-returning transfer, a rebase between observations, an allowance-change front-run — and predicts the over-credit or loss. Only after executing it on a disposable local fork against a mock token and observing credited > received (or a loss) does it record a FINDING. It does not declare the protocol "safe" from inspection; whether a real listed token triggers the defect is a runtime property that stays UNKNOWN until exercised.
How RootRaven analyzes it
- Support level
- Verified — requested-vs-received, return-value, rebasing, and approval-race defects are deterministically reproducible against mock tokens; RootRaven both flags the pattern and reproduces the accounting break.
- Evidence model
- Evidence-first. A requested-amount credit is a signal; a Finding requires executing a deposit/withdraw against a configured fee-on-transfer/rebasing/non-standard mock on a disposable local chain or fork and observing credited ≠ received or a loss.
- Declared property
- “Credited amount equals measured received balance; a transfer that did not succeed credits nothing; allowance changes cannot be front-run for combined value.”
RootRaven uses AI only to compose the candidate scenario — which token behaviour to configure (fee %, rebase direction, return style), which call ordering to try (including an allowance-change front-run). It then deploys the protocol and a deterministic mock token on a local blockchain or local fork and executes the scenario. A deterministic, AI-free verifier reads balances and internal credits before and after and checks the declared property: did shares/collateral exceed the measured received balance, or did a non-succeeding transfer still credit? Only a reproduced accounting break is a Finding. There is no live-network attack mode — proof is obtained only against disposable local state.
A clean run means no break was reproduced against the token behaviours exercised — UNKNOWN, not "safe" — and it says nothing about token behaviours not modeled. Coverage of exotic rebasing schedules and hook-based (ERC-777) reentrancy variants varies; keep the maturity caveat.
Mitigation
Measure what you receive, check every return, and make allowance changes race-safe.
- ▸Preventive: credit by balance delta — read balanceOf(this) before and after transferFrom and use the difference; never trust the requested amount.
- ▸Preventive: use OpenZeppelin SafeERC20 (safeTransfer/safeTransferFrom/forceApprove) so missing or false returns are handled and non-conforming transfers revert.
- ▸Preventive: for allowances, set to zero before setting a new non-zero value, or use safeIncreaseAllowance/safeDecreaseAllowance; prefer EIP-2612 permit with its nonce where supported.
- ▸Policy: restrict the admitted token set (allow-list) where feasible, and explicitly document (or reject) fee-on-transfer and rebasing tokens rather than silently mis-accounting them.
Corrected example
The vault credits the measured received amount using SafeERC20 and a balance-delta read:
synthetic — balance-delta + SafeERC20// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract SaferVault { using SafeERC20 for IERC20; IERC20 public token; mapping(address => uint256) public shares; function deposit(uint256 amount) external { uint256 before = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), amount); // reverts if not ok uint256 received = token.balanceOf(address(this)) - before; // measured delta shares[msg.sender] += received; // credit what actually arrived } function withdraw(uint256 shareAmount) external { shares[msg.sender] -= shareAmount; // Send a measured amount; for rebasing tokens derive the payout from // a current balance/share ratio rather than a cached figure. token.safeTransfer(msg.sender, shareAmount); } }
Why it works: the balance-delta read credits only what was actually received, so a fee-on-transfer token can no longer inflate shares; SafeERC20 reverts on non-standard or failed transfers, so an unsuccessful transfer credits nothing; using forceApprove / safeIncreaseAllowance for approvals avoids the non-zero-to-non-zero race.
Limits: balance-delta accounting is not reentrancy-safe by itself — hook-based tokens (ERC-777) can re-enter between the two balanceOf reads, so combine it with reentrancy protection. Rebasing tokens still need share/ratio accounting, not just delta-on-deposit. SafeERC20 handles return values but cannot make a fundamentally incompatible token behave canonically; some tokens are best rejected.
Verifying the mitigation
Verify on a local fork with mock tokens: deposit a fee-on-transfer mock and confirm credited shares equal the received (post-fee) amount, not the requested amount; make the mock return false and confirm the deposit reverts (no credit); rebase the mock between deposit and withdraw and confirm payouts track real balances; attempt an allowance-change front-run and confirm the race-safe path prevents combined spending. Report "no accounting break reproduced within the tested token behaviours", never immunity.
Verification covers the token behaviours you modeled; a real listed token with a behaviour you did not model remains UNKNOWN.
Limitations & honesty
- ▸Reproduction depends on the mock token behaviours configured; unmodeled behaviours (exotic rebases, ERC-777 hooks) stay UNKNOWN.
- ▸Balance-delta accounting still needs explicit reentrancy protection against hook-based tokens.
- ▸A clean run is UNKNOWN, not a guarantee that every admissible token is handled correctly.
- ▸No figures, incident counts, or CVE/SWC identifiers are asserted beyond the referenced, verifiable sources.
References
- [1]EIP-20: Token Standard (ERC-20)
- [2]EIP-2612: permit — signed approvals for ERC-20
- [3]OpenZeppelin — SafeERC20 & ERC-20 utilities
- [4]Solidity docs — interacting with other contracts & low-level calls
- [5]OWASP Smart Contract Top 10
- [6]Foundry Book — fork testing & mock tokens
References point to public, well-known technical sources for further study.
Related articles
