The ERC-20 that returns nothing still moved your money
Three behaviours in deployed token contracts break integrations that trust the return value of a transfer: contracts that return no data at all, contracts that refuse to raise an allowance that is not currently zero, and contracts that credit the receiver with less than the amount you passed. All three succeed on chain. All three leave your ledger wrong, because the thing you checked was the call and the thing that mattered was the balance.
I have run stablecoin rails in daily production since 2018, and every incident I have had in that area came from the same shape of mistake: some layer of the stack decided that a function signature in a standard was a description of what is actually deployed. It is not. The standard describes what a token should do. The chain holds what somebody compiled years ago and can never change.
The return value is a claim about the call, not about the money
ERC-20 says transfer and transferFrom return a bool. Plenty of widely used contracts (including the largest stablecoin by circulation) were written before that convention hardened, and they return nothing. The function body executes, the state changes, and the call returns zero bytes of data.
That is where naive client code fails, and it fails in the worst direction. A decoder that expects 32 bytes and gets zero either raises, or reads whatever the ABI layer decides to hand back for an empty buffer. So a transfer that actually moved money surfaces as an exception, or as False. The retry logic sees a failure, sends the transfer again, and now the money has moved twice.
In erc20-transfers I keep the decode step separate from the call for exactly this reason:
from erc20_transfers import (
decode_transfer_result,
decode_uint256,
encode_balance_of,
encode_transfer,
)
data = encode_transfer(to=bob, amount=1_500_000)
ok = decode_transfer_result(eth_call(token, data))decode_transfer_result exists because "the token said True", "the token said nothing", and "the token reverted" are three different outcomes, and only the third one means the transfer will not work. Collapsing the first two into a single boolean is the whole bug.
There is a second trap stacked on top of it, and it is the one I see most often in code review. eth_call, or .call() in web3.py, is a simulation. It runs the function against a local copy of state and returns what it would have returned. Nothing is signed, nothing reaches a mempool, no receipt is produced. A True out of .call() on transferFrom means "this would work", not "the tokens moved". Moving tokens needs a signed transaction and eth_sendRawTransaction. I have seen a service log a successful payout from a simulated call and mark an order as settled while the balance never changed.
Measure the effect, not the call
If the return value cannot be trusted and the simulation is not the transfer, the only ground truth is what balanceOf says before and after the transaction was mined. That is the rule the library is built on: check the effect on balances rather than trusting the call to revert.
receipt = send_and_wait(signed_tx)
before = decode_uint256(
eth_call(token, encode_balance_of(account=treasury), block=receipt.block_number - 1)
)
after = decode_uint256(
eth_call(token, encode_balance_of(account=treasury), block=receipt.block_number)
)
credited = after - beforeThe block pinning is not decoration. If you read "before" against latest, send, and read "after" against latest, any other transfer touching that address between the two reads lands inside your delta. For a hot treasury address that is not an edge case, it is Tuesday. Pinning both reads to the receipt block and its parent makes the delta a property of one transaction instead of a property of how busy you were. For a shared address it is still an approximation over everything in that block that touched it. If you need it exact, the account has to be exclusive to the flow, which is one more argument for per-purpose deposit addresses.
The fee case falls straight out of this. Some contracts deduct a fee from the transferred amount, so the receiver is credited less than the sender was debited. At least one heavily used stablecoin carries a fee rate in storage that currently happens to be zero: a value somebody can change, not a property of the compiled code. If your ledger credits the amount you passed in, you have written down a number the chain never agreed to, and it will drift away from the balance quietly until somebody reconciles by hand. Credit the measured delta. Debit the requested amount. If they differ, that difference is a real cost and belongs in the ledger as one, not as an unexplained gap.
The allowance is read, not assumed
transferFrom moves someone else's tokens and only works while that someone has approved the spender for at least the amount being moved. Short allowance means revert, and a revert from a simulated call is easy to misread as a transient node problem. So the allowance is read first and reported with the numbers in it, before any call data is built:
from erc20_transfers import check_allowance, decode_uint256, encode_allowance
allowance = decode_uint256(eth_call(token, encode_allowance(owner=alice, spender=bob)))
check = check_allowance(owner=alice, spender=bob, required=1_500_000, current=allowance)
if not check.sufficient:
print(check.explain())
# 0x2222...2222 holds an allowance of 400000 from 0x1111...1111, but
# transferFrom of 1500000 needs 1100000 more; the call reverts until the
# owner approves at least 1500000That sentence is why the module exists. "Execution reverted" tells an operator nothing at three in the morning. The shortfall, the owner, the spender and the amount that needs approving tell them what to do next.
The non-standard twist here is the reset requirement. Some contracts reject an approve that raises a non-zero allowance to a different non-zero value, and the original reasoning was to close a front-running window between the old and new limits. To raise such an allowance you must first set it to zero, then set the new value. That is two transactions and two nonces, and the state in between is a real state your process can die in: allowance zero, nothing approved, and a queue of payouts that all revert.
So the top-up is a small state machine, not a function call. Whatever encodes your approve, the discipline around it is the same three rules. Re-read the allowance from the chain before every attempt instead of assuming your last approve landed. Treat "currently zero" as a resumable position rather than an error. And do not build transferFrom call data from a remembered number: encode_checked_transfer_from refuses to produce call data when the allowance it was handed does not cover the amount, which turns a future revert into an immediate, explainable failure.
Decimals belong to the token too
While I am refusing to assume things about contracts, the number of decimals is one of them. USDT and USDC use 6, DAI and most others 18, WBTC 8. Assuming 18 against a 6-decimal token inflates a payout by a factor of a million. Read it and pass it explicitly:
from decimal import Decimal
from erc20_transfers import decode_decimals, encode_decimals, to_units
decimals = decode_decimals(eth_call(token, encode_decimals()))
amount = to_units(Decimal("1.5"), decimals=decimals) # 1500000 on USDTConversion is exact by construction: amounts are Decimal, floats are refused, and a value with more precision than the token can hold raises instead of silently dropping the digit. A rounding mode you did not choose is a rounding mode you will eventually have to explain to somebody counting their money.
What I would do differently
For a long time I handled this with a quirks table: a per-token map of "returns no bool", "needs zero reset", "takes a fee". It works right up until a token you have not classified shows up, or a contract with an upgradeable proxy changes behaviour underneath your entry. The table is a cache of the chain's state maintained by hand, and it goes stale the way every hand-maintained cache does.
What I would build from the start now is the inverse: assume every token is non-standard, and let measurement be the normal path rather than the fallback. Decode the return value if there is one, and treat its absence as an absence rather than as a denial. Take the credited amount from the balance delta at the receipt block for every transfer, not just for the ones flagged as fee-taking. Read decimals and the allowance from the chain each time, cheaply, rather than remembering them. A well-behaved token costs you two extra eth_calls under that regime. A badly behaved one costs you nothing extra at all, because it is being handled by the same code as everything else.
The library is at github.com/polycratia/erc20-transfers. It is pre-alpha and the public API is not stable yet, but the shape of the argument is stable: on chain the return value is a claim, and the balance is the evidence.