Non-standard tokens: USDT without a return value, fee-on-transfer and blacklists
What surprises ERC-20 tokens hide - USDT with no return value, transfer fees, address blocking, different decimals - and how an escrow contract defends itself.
In this article
The ERC-20 standard describes how a token should behave. In practice many popular tokens behave differently, and every difference is a potential bug in a contract working with them. An escrow that accepts other people's tokens has to account for all of them. Let us go through the main "surprises".
1. USDT does not return a value
By the standard transfer and transferFrom return a bool: true on success. But USDT on Ethereum was written before the standard settled and returns nothing. If a contract writes:
bool ok = IERC20(token).transfer(to, amount); // for USDT: a decoding error!the call fails: Solidity expects 32 bytes of reply and gets emptiness. A contract that ignores this cannot work with the most popular stablecoin.
How it is solved. The SafeERC20 wrapper from OpenZeppelin is used. It calls the function "manually" and counts both an empty reply and true as success. For automatic payouts, where a transaction must not be reverted, similar logic is written explicitly in our contract:
(bool ok, bytes memory ret) = token.call(abi.encodeCall(IERC20.transfer, (to, amount)));
if (!ok) return false;
if (ret.length == 0) return true; // this is how USDT and similar behave
return ret.length >= 32 && abi.decode(ret, (bool));2. A fee on transfer (fee-on-transfer)
Some tokens withhold part of the amount on each transfer: you sent 100, the recipient got 98. A contract that recorded "received 100" in the deal was wrong - the balance actually holds 98, and on payout the last recipient is left without money.
How it is solved. Check the actual receipt: measure the balance before and after and compare.
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore;
if (received != amount) revert AmountMismatch(amount, received);A token with a fee is rejected at once instead of writing a wrong amount into the deal. This also cuts off rebasing tokens whose balance changes by itself over time.
3. Address blocking
For USDT and USDC the issuer can add an address to a blacklist - after which any transfer to or from it reverts. For escrow this is serious: if the seller is on the blacklist, the payout to them fails and, in a naive implementation, blocks closing the deal.
How it is solved. The payout goes through a "try": if it fails the amount is recorded in credits (pull payments) and the deal closes. The other participants get their money, and the problematic recipient gets a debt record. If the address is blocked permanently they cannot collect the money, but it does not harm the others.
4. Different decimals
Tokens store amounts as integers and "know" how many decimals are implied:
| Token | Network | Decimals | 1 token in "raw" units |
|---|---|---|---|
| USDT, USDC | Ethereum, Polygon, Base | 6 | 1 000 000 |
| USDT, USDC | BNB Chain | 18 | 1 000 000 000 000 000 000 |
| DAI | everywhere | 18 | 1 000 000 000 000 000 000 |
The most common mistake is to hard-code 6 or 18. Then everything works on one network, and on another the user types "100" and the contract accepts a trillion times more or less. The rule: decimals are always taken from the data of the specific network and token, never from a constant in code. That, incidentally, is why the interface warns "18 decimals, not 6" on BNB Chain.
5. Tokens that return false instead of reverting
Some tokens on error do not revert but return false. If a contract does not check the result it decides the transfer succeeded though no money moved. SafeERC20 checks the return value and reverts.
6. Tokens with hooks (ERC-777 and similar)
ERC-777 tokens call the recipient's and sender's code on transfer. This opens the door to reentrancy where you did not expect it: even a "harmless" token transfer can call foreign code. The defence is the same: the order "checks → effects → interactions" and nonReentrant.
7. Upgradable and pausable tokens
USDC is a proxy contract: the issuer can upgrade its logic, and USDT has a "deprecate" mechanism allowing the token to be redirected to a new implementation. Both can pause transfers entirely. This is not a bug but a property of centralised stablecoins, but it must be accounted for: an escrow contract's "guarantee" is no stronger than the reliability of the token it handles. More in the article on stablecoins.
8. The approval race and the USDT quirk
The standard approve problem: if you change an approval from 100 to 50, the recipient has a window to spend the old 100 and the new 50. USDT on Ethereum has a different kind of protection: changing a non-zero approval to another non-zero is not allowed - reset it to zero first. So the interface, when needed, asks for three signatures instead of two. More in the article on approve.
A token whitelist
All these risks exist because a contract trusts "foreign code". So our escrow has a whitelist of allowed tokens (allowedToken). The owner enables only vetted ones: USDT, USDC, DAI and the native coin. An arbitrary token with exotic behaviour cannot create a deal. And the interface shows the list of currencies from the contract's data, not from a "directory": if a token is not allowed, it is hidden.
A developer's checklist
- Always wrap token calls (
SafeERC20). - Check the actually received amount, not the declared one.
- Do not hard-code
decimals. - Do payouts through a "try" with credits so one recipient cannot block the others.
- Limit the set of tokens with a whitelist.
- Test on "evil" mocks: a token without
bool, with a fee, with a blacklist, with hooks - see the article on testing.
What to read next
A full overview of typical contract mistakes is in the catalogue of vulnerabilities. Then: how contracts can be upgraded and why that is risky - proxies and upgradeability, and why the escrow's address is the same on four networks - CREATE2.
Keep reading
- 3 · Under the hood
ERC-20 from scratch - writing a token in Solidity and analysing every line
A minimal ERC-20 implementation in Solidity: balances, transfers, allowances, events, common mistakes and why proven libraries are needed in production.
5 min read - 3 · Under the hood
Reentrancy, checks-effects-interactions and pull payments
How the reentrancy attack that hacked The DAO works and how to defend against it: operation order, ReentrancyGuard and deferred payments, using escrow.
6 min read
Mentioned in
- What is cryptocurrency and where do coins come from
- What is a smart contract and why you can trust it with money
- Tokens and NFTs - what they are, how they differ and where they are used
- Stablecoins: USDT, USDC, DAI - how they differ and what the risks are
- The deal lifecycle - from offer to payout, step by step
- How to choose a network for a deal - Ethereum, Base, Polygon or BNB Chain
- ERC-20 and approve: why the “approval” and what unlimited means
- How to verify a smart contract before using it - a step-by-step guide
- A tour of the Solidity language - types, functions, errors and events by example
- ERC-20 from scratch - writing a token in Solidity and analysing every line
- Integer math and rounding in smart contracts
- Reentrancy, checks-effects-interactions and pull payments
- A catalogue of smart contract vulnerabilities: 12 classic mistakes and defences
- Foundry for beginners - compiling, tests, fuzzing and a local network
- Testing smart contracts: unit, fuzz, invariants and static analysis
Ready to try it?
Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.

