Fee on transfer
What the token guarantees, four integration patterns with their trade-offs, and the inversion everyone gets wrong by hand.
All docs
This is the bit that breaks integrations, and the one the framework exists to make survivable. The failure is not the fee. It is quoting against the amount you send when the recipient receives less.
What the token guarantees
- Exact debit
- The sender is always debited exactly the amount named in the call. A transfer of 100 moves exactly 100 out of the sender. The fee is withheld from that 100, never added on top.
- computeFee is exact
- Not an estimate. In the same transaction, a transfer with those arguments withholds precisely that much. The suite fuzzes it against what left the sender minus what reached the recipient.
- maximumFee is tight
- An upper bound over every amount under the current configuration, and reachable — some amount actually produces it. It reports zero while the rate is zero, so it stays a bound on the current fee rather than a number merely never exceeded.
- The ceiling cannot move
- MAX_FEE_BASIS_POINTS is a compile-time constant of 1000, which is ten percent. The rate and the cap are authority-mutable; this is not, for the lifetime of the deployment.
- Supply is never charged
- Mint and burn skip the fee phase entirely.
- Both legs are visible
- A Transfer to the vault for the fee, then a Transfer to the recipient for the remainder. An indexer summing events sees value conserved.
Pattern A — ask first
One view call, exact, no balance snapshots and no extra reads on the transfer path. This is the recommended route for anything that needs a number before it commits.
uint256 fee = IERC20TransferFee(token)
.computeFee(msg.sender, address(this), amountIn);
uint256 willArrive = amountIn - fee;
// Quote, price or reserve against willArrive, then move the gross amount.
IERC20(token).transferFrom(msg.sender, address(this), amountIn);Pattern B — measure after
The classic, and still what you must do for tokens outside this framework. It is correct. Its cost is that you cannot know what arrived until after you have committed, so anything needing a quote up front — a router comparing paths, a lending market sizing a position — has to transfer first and unwind on failure, or guess and pad with slippage.
uint256 before = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amountIn);
uint256 received = IERC20(token).balanceOf(address(this)) - before;Pattern C — name the output
Use this when the arriving number has to be one you chose: matching an off-chain quote, hitting a round tick, settling a fixed obligation. The contract receives exactly what you asked for, and the return value is what left the sender.
uint256 paid = IERC20TransferFee(token)
.transferFromExactOut(msg.sender, address(this), 1_000e18);Inverting the fee by hand is where integrations go wrong. Dividing the output by one minus the rate is off by one for most values, because you are inverting a floored ratio with another floor, and it ignores the absolute cap entirely. The token solves it exactly, including across the boundary where the cap takes over from the rate, and the input it computes is the smallest one that produces the output — so the sender never overpays by rounding.
Pattern D — price against the ceiling
No per-transaction view call, and the quote survives the authority raising the rate between the moment you produced it and the moment it lands. That is the property an off-chain quote pipeline or a batched executor actually needs.
uint256 worstCase = IERC20TransferFee(token).maximumFee();
uint256 conservativeIn = amountIn > worstCase ? amountIn - worstCase : 0;Exemptions
isFeeExempt(account) is true when either side of a transfer waives the fee. Pools are the usual case: an issuer who wants their token to trade normally exempts the pool, and the naive integration path starts working again. The flag stays set regardless, because an exemption is configuration and can be revoked in one transaction.
The fee vault is exempt by construction, without needing to be configured. Charging a fee on the vault's own withdrawals would make the fee recursive and would let it accumulate a balance it can never fully move.