Skip to main content

Type a word from a question or an article title.

3 · Under the hood

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.

5 min readUpdated:
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:

solidity
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:

solidity
(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.

solidity
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:

TokenNetworkDecimals1 token in "raw" units
USDT, USDCEthereum, Polygon, Base61 000 000
USDT, USDCBNB Chain181 000 000 000 000 000 000
DAIeverywhere181 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.

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.

Mentioned in

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.