Status 1 proves the transaction ran, not that the tokens moved
A mined ERC-20 transfer hands you two witnesses, and they do not say the same thing. The receipt status tells you whether execution reverted. The Transfer log is the chain's own record that a balance changed. A token that returns false without reverting reports status 1 while nothing moves, so a confirmation routine that credits a user on status alone will eventually credit them for a payout that never arrived.
I have shipped payments and crypto rails in production since 2018, and this is not an exotic edge case. It is what code written against the happy path does by default.
The cheap failure and the expensive one#
A transfer that reverts is the cheap failure. Gas is spent, nothing moves, the error says why, and the next attempt can be better. Retry logic sees it, alerts fire, the ledger stays clean.
The expensive failure is the quiet one. ERC-20 specifies a bool return, and nothing in the standard forces a token to revert when it declines to move anything. Returning false is a legal way to fail. The transaction is mined, the receipt carries status 1, the gas is gone, and the balances sit exactly where they were. If your confirmation step is
if receipt["status"] == 1:
credit(user, amount)then you have booked money into a user's balance on the strength of a number that only means "the EVM did not throw". And amount is the number you asked for, not the number that landed. Two assumptions in one line, both failing silently.
The same shape shows up one layer earlier, in the return value itself. read_transfer_return in erc20-transfers keeps the distinction explicit: a token that returns no data at all is read as success, because it predates the finalised standard and the call not reverting is the only signal there is, while a token that returns false gets an explanation written for a human rather than a dashboard (the tokens did not move, the gas was still spent, and the receipt still carries status 1).
A necessary gate, then the actual proof#
So the status is a gate, and the matching log is the proof. confirm_transfers reads a receipt in that order, and raises a distinct error for each distinct absence.
from erc20_transfers import (
NothingMoved,
TransactionReverted,
confirm_transfers,
)
try:
events = confirm_transfers(receipt, token=token, to=recipient)
except TransactionReverted:
# execution failed; nothing moved, and the chain says why
...
except NothingMoved:
# status 1 over balances that did not change
...
else:
credited = sum(event.value for event in events)Status runs first. A receipt without a status key raises ValueError, because an unmined transaction has moved nothing and "did it succeed" is not yet a question with an answer. The value may be an int or a hex string, and only 0 or 1 is accepted. Then the token and the recipient go through normalize_address, status 0 raises TransactionReverted, a receipt without a logs key raises ValueError, and find_transfers is asked for the Transfer events of that token credited to that recipient. An empty result raises NothingMoved, whose message states what the shape means: a token returning false without reverting looks exactly like this. Whatever is left comes back as TransferEvent values in emission order.
Four absences, four errors, and that separation is the operational point. A revert is usually mine to fix: a missing allowance, a short balance, a fee ceiling set too low. NothingMoved is the token's, and it is the one that needs a human, because retrying it blindly just spends gas on the same non-event. A receipt missing a key is my own plumbing handing the confirmation step something truncated, a caller bug rather than a chain event. One TransferFailed exception covering all three is convenient at the call site and useless at three in the morning.
The amount is in the log, not in the request#
Once you are reading the log anyway, the second correction comes free: the amount. decode_transfer_log takes the value from the log's data word, never from the call data.
from erc20_transfers import decode_transfer_log
event = decode_transfer_log(log)
print(event.explain())
# 1500000 units of 0x... moved from 0x... to 0x...; the log is in the
# receipt, so this is what the chain recorded rather than what the call
# data asked forThat sentence is the design. A fee-on-transfer token delivers less than was sent, and the gap is invisible to anything that trusts the request. The package will not guess at it either: a fee is only ever learned by comparing balances around the transfer, never derived from the amount that was asked for. So the number you credit is sum(event.value for event in events), not the integer you encoded into the call.
The filter matters as much as the arithmetic. find_transfers(logs, token=..., to=...) keeps only the Transfer events of that token credited to that recipient, in emission order. A receipt can legitimately contain a transfer of the same token to a fee collector, or an Approval from the same call. Neither of those confirms your payout, and neither should be summed into it.
One more thing before the number becomes money: event.value is an integer of the token's smallest unit, and how many of those make one token is the token's own business. Six decimals, eighteen, eight, sometimes none. TokenAmount exists to keep an on-chain integer together with the scale it was read at, which makes the integer harder to pass on without it. A confirmed log with an unqualified amount is still a bug waiting for a six-decimal token.
Look-alikes, and two ways of saying no#
ERC-721 uses the same Transfer signature hash as ERC-20. The difference is where the third value lives: an NFT indexes the token id as a fourth topic and leaves the data empty, where ERC-20 puts the amount in the data word. Read one as the other and token id 7 becomes a payout of 7 units.
nft_log = {
"address": NFT,
"topics": [
TRANSFER_TOPIC_HEX,
address_topic(ALICE),
address_topic(BOB),
value_word(7),
],
"data": "0x",
}
decode_transfer_log(nft_log) # raises NotATransferLogSo decode_transfer_log refuses: a missing address, topics or data, a first topic that is not the Transfer signature, a topic count that is not three, a data field that is not exactly one 32-byte word, or an indexed address that is not left-padded with zero bytes. Each of those is a separate NotATransferLog with the reason in the message.
find_transfers does the opposite: it passes over. _is_transfer_log keeps a log only when its topics are a sequence of exactly three entries whose first is the 32-byte TRANSFER_TOPIC, and anything else is skipped without comment, including a log with no topics at all.
The asymmetry is deliberate, and it is the part I would argue for in review. A receipt's logs are a mixed bag you did not choose, so a filter that throws on the first Approval event is unusable, and non-matches are simply not matches. A single log handed to the decoder is a claim by the caller that this log is an ERC-20 Transfer. A wrong claim there is a bug in my code, and it should be loud rather than returning a plausible TransferEvent built from an id.
What I would do differently#
In earlier payout systems I built, the credit took the requested amount (it was already sitting in the row) and logs were parsed later, in a reconciliation job. That ordering is backwards. It makes the job the thing that discovers discrepancies, hours after the ledger has been read by users and by accounting, and it guarantees that every such discovery is a correction rather than a refusal. Letting the credit take its number from the event inverts that: there is less for reconciliation to find, and what it does find tends to be infrastructure rather than arithmetic.
The second change is the error taxonomy, and I would make it on day one now. Distinct exception types are not ceremony. They are the only way the retry policy and the on-call runbook can disagree about what to do, which they should, because a revert and a false return are not the same incident.
Close#
Status 1 is a statement about execution. A Transfer log credited to your recipient, in the same receipt, with the amount in its data word, is a statement about money. Keep the two apart in the confirmation path and the quiet failure stops being quiet.