BchainPay logoBchainPay
EngineeringEVMStablecoinsSecurity

Fee-on-Transfer ERC-20 Tokens: Safe Handling for Crypto Gateways

How to detect fee-on-transfer ERC-20s, measure actual received amounts via balance delta, and prevent silent merchant balance shortfalls in a crypto gateway.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for Fee-on-Transfer ERC-20 Tokens: Safe Handling for Crypto Gateways

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:

  1. Customer sends 100 AMPL. Gateway measures delta = 100 AMPL. Intent confirmed.
  2. Four hours later, before the nightly treasury sweep, AMPL rebases down 8%.
  3. 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 tokenstransfer() 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 Transfer event 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 (balanceOf at blockN - 1 vs blockN) for every deposit, regardless of whether the token is flagged as a fee token.
  • Classify tokens at onboarding using a SimTransfer helper called via eth_call with state overrides. Store feeBps and 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 swapExactTokensForTokensSupportingFeeOnTransferTokens in Uniswap v2 and set a conservative amountOutMinimum in v3 when swapping tokens with known fees during settlement.
  • Populate token_fee_bps and received_amount (distinct from expected_amount) in your payment_intent.succeeded webhook so merchants and their accountants can reconcile line items correctly.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading