The ERC-20 standard says transfer(recipient, amount) should move amount
tokens to recipient. Most implementations do exactly that. A non-trivial
subset do not: they deduct a percentage on every transfer and route it to a fee
collector, a redistribution pool, or a burn address. You send 100; the
recipient gets 95; the contract quietly pockets 5.
The category is larger than most engineers expect when they first encounter it. SHIB ecosystem spin-offs, reflection tokens that redistribute on every transfer, older BEP-20 "deflationary" launches from 2021-2022, and charity/staking tax tokens all fall here. USDT's Ethereum contract even has a fee mechanism baked in — it has always been set to zero, but the code path is live on-chain, and USDT forks on other chains have activated it at non-zero values.
For a payment gateway the failure mode is specific and painful: a merchant creates a payment intent for 100 USDT equivalent in some token; the customer sends the right amount; the gateway receives 93 tokens; the intent never confirms or, worse, it confirms and the merchant gets shorted.
What fee-on-transfer looks like at the contract level#
The ERC-20 Transfer(address indexed from, address indexed to, uint256 value)
event fires once (or twice) per transfer. The critical subtlety: the value
field reflects what was debited from the sender's balance, not what was
credited to the recipient. Some contracts emit a second Transfer event for
the fee leg; others do not. You cannot rely on log parsing alone.
// SPDX-License-Identifier: MIT
// Simplified fee-on-transfer pattern (illustrative)
function _transfer(address from, address to, uint256 amount) internal {
uint256 fee = (amount * feeBps) / 10_000;
uint256 settled = amount - fee;
_balances[from] -= amount;
_balances[to] += settled; // recipient gets less
_balances[feeCollector] += fee; // fee wallet absorbs the rest
emit Transfer(from, to, amount); // event shows full `amount`!
emit Transfer(from, feeCollector, fee);// second event, sometimes omitted
}An indexer that sums up Transfer event values to a deposit address will
over-count every single deposit. The only reliable source of truth is the
balance delta between two block states.
Detecting fee-on-transfer tokens at onboarding#
When a merchant enables a new ERC-20 in their BchainPay dashboard, the token
goes through a classification step before it is added to the accepted-token
list. We use a SimTransfer helper contract deployed on every supported chain.
The contract has a single non-view function that executes a transferFrom and
returns the recipient's balance delta — but we call it exclusively via
eth_call with a state override that pre-funds the probe address and grants
allowance, so no transaction is ever broadcast.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @notice View-only simulation helper. Called via eth_call + stateOverride.
/// Never deployed as a callable on-chain entrypoint; no funds are ever held here.
contract SimTransfer {
/// @return received Tokens actually credited to `to` after the transfer.
function simulate(
address token,
address from,
address to,
uint256 amount
) external returns (uint256 received) {
uint256 before = IERC20(token).balanceOf(to);
IERC20(token).transferFrom(from, to, amount);
received = IERC20(token).balanceOf(to) - before;
}
}The TypeScript caller uses eth_call with stateOverride to inject a
synthetic balance and allowance for the probe address:
// lib/token/classify.ts
import { createPublicClient, http, parseAbi, toHex, pad } from "viem";
const SIM_ABI = parseAbi([
"function simulate(address,address,address,uint256) returns (uint256)",
]);
const PROBE = "0xdead000000000000000000000000000000000001" as const;
const SINK = "0xdead000000000000000000000000000000000002" as const;
const AMOUNT = 1_000_000n; // 1 unit at 6 decimals; scale to token's decimals
export async function measureFeeOnTransfer(
client: ReturnType<typeof createPublicClient>,
tokenAddress: `0x${string}`,
simHelper: `0x${string}`
): Promise<{ feeBps: number }> {
// allowanceSlot = keccak256(abi.encode(probe, keccak256(abi.encode(simHelper, 1))))
// For well-known tokens this is slot 1; adjust per token storage layout.
const stateOverride = {
[tokenAddress]: {
stateDiff: {
// balance[PROBE] = AMOUNT (storage slot: keccak256(PROBE . 0))
[balanceSlot(PROBE)]: pad(toHex(AMOUNT)),
// allowance[PROBE][simHelper] = AMOUNT (storage slot: keccak256(simHelper . keccak256(PROBE . 1)))
[allowanceSlot(PROBE, simHelper)]: pad(toHex(AMOUNT)),
},
},
};
const received = await client.call({
to: simHelper,
data: encodeFunctionData({ abi: SIM_ABI, functionName: "simulate",
args: [tokenAddress, PROBE, SINK, AMOUNT] }),
stateOverride,
});
const actualReceived = decodeFunctionResult({ abi: SIM_ABI,
functionName: "simulate", data: received.data! });
const feeBps = Number(((AMOUNT - actualReceived) * 10_000n) / AMOUNT);
return { feeBps };
}A feeBps of 0 means the token is standard. A value above 0 triggers the
fee-on-transfer flag in the token registry. Above 1500 (15%) we classify the
token as a reflection token and block it from the standard acceptance flow.
Runtime balance-delta measurement#
Detection at onboarding is a backstop, not a guarantee. Fee parameters can
change after a proxy upgrade (it happens). For every ERC-20 deposit on every
chain, BchainPay measures the actual received amount at settlement time using a
balance-delta approach regardless of the stored feeBps:
// lib/deposits/measureReceived.ts
import { publicClient } from "@/lib/rpc";
import { erc20Abi } from "viem";
export async function measureReceivedAmount(
tokenAddress: `0x${string}`,
depositAddress: `0x${string}`,
txHash: `0x${string}`
): Promise<bigint> {
const receipt = await publicClient.getTransactionReceipt({ hash: txHash });
const blockNumber = receipt.blockNumber;
const [balanceBefore, balanceAfter] = await Promise.all([
publicClient.readContract({
address: tokenAddress,
abi: erc20Abi,
functionName: "balanceOf",
args: [depositAddress],
blockNumber: blockNumber - 1n, // parent block = pre-transfer state
}),
publicClient.readContract({
address: tokenAddress,
abi: erc20Abi,
functionName: "balanceOf",
args: [depositAddress],
blockNumber, // confirmed state
}),
]);
return balanceAfter - balanceBefore; // actual received, not event value
}Two eth_call requests at adjacent block numbers cost nothing and handle
every edge case: multiple Transfer events in the same transaction, tokens that
don't emit secondary events for fees, and tokens that batch several user
operations into one block.
Rebasing tokens: a separate failure mode#
Rebasing tokens adjust every holder's balance periodically without a
Transfer event. AMPL (Ampleforth) is the canonical example; the pattern also
appears in staked-ETH wrappers and elastic supply experiments. The balance-
delta check still works at deposit time, but a settlement timing window
introduces a second exposure:
- Customer sends 100 AMPL. Gateway measures delta = 100 AMPL. Intent confirmed.
- Four hours later, before the nightly treasury sweep, AMPL rebases down 8%.
- Sweep collects 92 AMPL. Merchant was credited 100.
The fix is to close the window: rebasing tokens must be swept immediately rather than batched into the normal hourly sweep job.
// lib/deposits/rebaseGuard.ts
const KNOWN_REBASING: Set<string> = new Set([
"0xd46ba6d942050d489dbd938a2c909a5d5039a161", // AMPL (mainnet)
// add others during token classification
]);
export async function scheduleSettlement(
tokenAddress: `0x${string}`,
depositAddress: `0x${string}`,
received: bigint
): Promise<void> {
const isRebasing = KNOWN_REBASING.has(tokenAddress.toLowerCase());
if (isRebasing) {
// bypass the batch queue; hit the sweep endpoint directly
await sweepNow(depositAddress, tokenAddress, received);
} else {
await enqueueSweep(depositAddress, tokenAddress, received);
}
}BchainPay's default configuration blocks rebasing tokens unless a merchant explicitly opts in. When a merchant enables a rebasing token, a banner in their dashboard reads: "Settlement is immediate — amounts are locked at deposit, not at batch time."
Webhook payload for fee-adjusted payments#
When BchainPay fires payment_intent.succeeded, the amount field always
reflects what the deposit address actually received. The pre-fee amount sent by
the customer is available in sender_amount. Fee metadata is included so
merchants can reconcile:
{
"event": "payment_intent.succeeded",
"data": {
"id": "pi_01JVHX...",
"currency": "SHIB",
"expected_amount": "100000000",
"received_amount": "95000000",
"sender_amount": "100000000",
"token_fee_bps": 500,
"status": "succeeded",
"metadata": {
"fee_type": "fee-on-transfer",
"fee_note": "5% deducted in-contract before credit"
}
}
}If received_amount falls below the merchant's configured tolerance threshold,
the intent transitions to partial (within tolerance) or underpaid (outside
it). Tolerance is set per-token in the dashboard; USDC/USDT default to 0 bps,
SHIB and other community tokens default to 1000 bps (10%).
What to refuse outright#
Not every fee-on-transfer token is worth supporting. BchainPay's classifier hard-blocks two categories:
Honeypot tokens — transfer() always reverts for addresses that are not
the deployer. The SimTransfer.simulate() call reverts and the token is flagged
as non-transferable and rejected during onboarding.
High-fee reflection tokens — tokens where each transfer both charges a fee AND redistributes to all holders (classic SafeMoon pattern). The combined effect makes accurate per-deposit accounting impractical because the deposit address balance drifts upward between deposit and sweep due to passive redistribution. Above 1500 bps we classify the token as a reflection token and reject it unless the merchant contacts support for a manual review.
Token swaps during settlement#
If your settlement pipeline swaps received tokens for USDC before crediting the
merchant's account, you must use the fee-on-transfer variant of the Uniswap
router. The standard swapExactTokensForTokens will revert whenever in-
transfer fees cause the actual output to fall short of amountOutMin:
import { encodeFunctionData } from "viem";
const calldata = encodeFunctionData({
abi: UNISWAP_V2_ROUTER_ABI,
// Use the *SupportingFeeOnTransferTokens variant — not swapExactTokensForTokens.
// The standard variant performs an exact-output check that fee tokens always fail.
functionName: "swapExactTokensForTokensSupportingFeeOnTransferTokens",
args: [amountIn, minAmountOut, [tokenIn, USDC_ADDRESS], recipient, deadline],
});Uniswap v3 handles this the same way: use exactInputSingle with
amountOutMinimum set to the post-fee expected value; the router does not
enforce an exact-input invariant, so it tolerates fee deductions in flight.
Key takeaways#
- Never use
Transferevent values as received amounts for arbitrary ERC-20 tokens. The value field reflects what left the sender; fees are deducted before the recipient's balance updates. - Measure balance delta (
balanceOfatblockN - 1vsblockN) for every deposit, regardless of whether the token is flagged as a fee token. - Classify tokens at onboarding using a
SimTransferhelper called viaeth_callwith state overrides. StorefeeBpsand token class in the token registry. - Rebasing tokens need immediate settlement. Do not batch them into hourly sweep jobs or the balance window can shift between deposit confirmation and sweep.
- Block honeypots and high-fee reflection tokens at classification time. Finding them at settlement time is much more expensive.
- Use
swapExactTokensForTokensSupportingFeeOnTransferTokensin Uniswap v2 and set a conservativeamountOutMinimumin v3 when swapping tokens with known fees during settlement. - Populate
token_fee_bpsandreceived_amount(distinct fromexpected_amount) in yourpayment_intent.succeededwebhook so merchants and their accountants can reconcile line items correctly.