Token, NFT and smart contract flows · Lesson 1 of 6
Reading token transfers
8 min read
On account-based chains, most of the value an analyst follows moves as tokens rather than as the native coin. A token balance is just a number inside a contract, and the only practical record of how it changed is the event log the contract chose to emit. Reading those logs correctly, and knowing when they do not tell the whole story, is the difference between a defensible flow analysis and one that is off by a factor of a trillion.
Tokens are contract state, transfers are events
An ERC-20 token (EIP-20) is a contract that keeps a mapping from addresses to balances. When someone "sends USDC", the transaction is not addressed to the recipient at all: its to field is the token contract (or a router that calls it), its native value is usually 0, and the recipient appears only inside the calldata and the logs. This is why an explorer's "transactions" tab and its "token transfers" tab for the same address can look completely different.
The standard requires the contract to emit an event whenever balances move:
event Transfer(address indexed _from, address indexed _to, uint256 _value)
Each log carries the emitting contract's address, up to four topics and a data field. The first topic, topic0, is the keccak-256 hash of the canonical event signature Transfer(address,address,uint256): no parameter names, no spaces, no indexed keyword. Every ERC-20 Transfer on every EVM chain therefore has the same topic0. Parameters marked indexed become the following topics, each left-padded to 32 bytes; the rest are ABI-encoded in data. Here is a raw log as eth_getLogs returns it (identifiers truncated):
{
"address": "0x7d1f…c3a0",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x000000000000000000000000a1b2c3d4…9f0e",
"0x000000000000000000000000b0b5e6f7…41aa"
],
"data": "0x0000000000000000000000000000000000000000000000000000000077359400",
"blockNumber": "0x1406f40",
"transactionHash": "0x4f3a…c21e",
"logIndex": "0x57"
}
Decoding it step by step: topic0 identifies the event; topic1 is _from (drop the 24 leading zero characters to get the 20-byte address); topic2 is _to; the data word is _value, 0x77359400 = 2,000,000,000 base units. The block is 21,000,000 and this is log number 87 in that block. The decoded record looks like this:
{
"event": "Transfer",
"token_contract": "0x7d1f…c3a0",
"from": "0xa1b2c3d4…9f0e",
"to": "0xb0b5e6f7…41aa",
"value_raw": "2000000000",
"decimals": 6,
"value": "2000.000000",
"block_number": 21000000,
"tx_hash": "0x4f3a…c21e",
"log_index": 87
}
Two cautions. First, the token's identity is the log's address, never its name or symbol: anyone can deploy a contract called "USDC" that emits perfectly formed Transfer events. Second, an ERC-721 NFT transfer has the same topic0 because its signature is also Transfer(address,address,uint256); the difference is that the token ID is indexed, so the log has four topics and empty data (see the lesson NFTs and multi-token standards). Always filter on topic count as well as topic0.
Events are written by the contract's own code. A malicious contract can emit Transfer events that match no balance change, and address poisoning campaigns exploit this: they generate zero-value or fake transfers "from" a victim to a lookalike address so that it appears in the victim's history and gets copied by mistake. Zero-value transfers and transfers of unlisted tokens should be filtered or flagged before analysis.
Decimals and units
The log stores an integer in base units. The decimals value that converts it to human units is not in the event; it comes from the contract's decimals() view function, which you should read once per contract and store with its source.
| Token (Ethereum mainnet) | Decimals | Raw value for 1 unit |
|---|---|---|
| USDC, USDT | 6 | 1,000,000 |
| WBTC | 8 | 100,000,000 |
| DAI, WETH, most tokens | 18 | 1,000,000,000,000,000,000 |
Applying 18 decimals to a 6-decimal token understates amounts by a factor of 10^12; the reverse overstates them by the same factor. The same asset can also use different decimals on different chains, because bridged and re-issued versions are separate contracts. Raw values are 256-bit integers (up to 78 digits), far beyond 64-bit integers and double-precision floats, so store them as strings or high-precision decimals and divide only at the presentation step (see the lesson Data quality, normalization and provenance).
Mints, burns and supply
EIP-20 says a contract that creates tokens should emit a Transfer with _from set to the zero address. Most implementations follow the mirror convention for burns, emitting a Transfer with _to set to the zero address. Sending tokens to a "dead" address such as 0x…dEaD is not a burn: the balance still exists and total supply does not change, even if nobody holds the key.
Not every token follows the convention. Some emit custom Mint and Burn events instead of, or in addition to, Transfer. WETH (the original WETH9 contract) emits Deposit when ETH is wrapped and Withdrawal when it is unwrapped, with no Transfer event, so a WETH balance rebuilt from Transfer logs alone is wrong for any address that wraps or unwraps. A simple supply reconciliation catches these cases:
-- Net supply from Transfer events vs totalSupply() read at the same block
SELECT
SUM(CASE WHEN from_address = '0x0000000000000000000000000000000000000000'
THEN value_raw ELSE 0 END)
- SUM(CASE WHEN to_address = '0x0000000000000000000000000000000000000000'
THEN value_raw ELSE 0 END) AS supply_from_events
FROM token_transfers
WHERE token_contract = '0x7d1f…c3a0'
AND block_number <= 21000000;
The query sums mints minus burns up to block 21,000,000 in a decoded token_transfers table. Compare the result with totalSupply() called at that block; a mismatch means the contract mints or burns through events your decoder does not read.
Approvals and transferFrom
ERC-20 lets an owner authorize a spender with approve(spender, amount), which emits Approval(address indexed owner, address indexed spender, uint256 value). The spender, typically a DEX router or a lending pool, later calls transferFrom(owner, recipient, amount). In the resulting Transfer event, from is the owner whose tokens moved, not the account that sent the transaction. Analysts who equate "transaction sender" with "source of funds" get every router-mediated trade wrong.
Approvals matter in investigations:
- Unlimited approvals set the value to 2^256 − 1 (all 64 hex digits
f). A spender with such an approval can move the owner's entire balance at any time. - Approval drainers trick a victim into approving an attacker-controlled spender. With permit (EIP-2612) the victim only signs an off-chain message; the attacker submits it, pays the gas, and the Approval event appears in a transaction the victim never sent.
- Approvals link addresses, which is useful for clustering (see the lesson Account-model heuristics).
Allowances cannot always be rebuilt from events. Spending an allowance through transferFrom reduces it, but not every implementation emits a new Approval when that happens; OpenZeppelin's widely copied library stopped doing so in version 5.0. For the current allowance, call allowance(owner, spender) at the block of interest.
Tokens that break the simple model
The neat rule "balance = sum of incoming minus outgoing Transfer values" fails for several common designs.
| Design | What happens | What to do |
|---|---|---|
| Fee-on-transfer | A transfer of 100 delivers, say, 98; 2 go to a fee wallet or are burned, often as a second Transfer event | Use the amount received in the Transfer to the recipient, not the calldata amount |
| Rebasing | Balances change without any Transfer event (supply expands or contracts, or rewards accrue) | Read balanceOf at block heights; model the rebase |
| Share-based | The contract tracks shares; displayed balances are derived and round down by a few base units | Track the share events where they exist |
| Freezable or pausable | The issuer can block an address or burn its balance through custom functions | Decode the issuer's admin events too |
Lido's stETH is a well-known rebasing example: holder balances grow when the protocol's oracle reports rewards, with no Transfer emitted, and transfers can leave 1–2 wei behind because of share rounding. stETH also emits a TransferShares event next to each Transfer. Its non-rebasing wrapper, wstETH, behaves like an ordinary ERC-20. Also remember that native ETH moved inside contract calls emits no log at all; it appears only in traces (see the lesson Raw Ethereum transactions, receipts and logs).
A reconciliation routine
Before reporting token flows for an address or a contract, run a short check:
- Fix the token by contract address and chain ID, and record
decimals(). - Pull Transfer logs with the right topic0 and three topics, from the contract's deployment block to your cut-off block.
- Rebuild balances for the addresses that matter.
- Compare them with
balanceOfread at the cut-off block, and total supply withtotalSupply(). - Explain every difference (rebasing, fees, custom mint events, WETH-style deposits) or state it as a limitation.
The lesson Reconstructing balances and histories covers balance snapshots in more depth; the point here is that token events are claims made by contract code, and state queries are how you test them.
Exam focus: the Transfer event signature and its topic0 (0xddf252ad…); which parameters are topics and which are data; why the log's contract address, not the symbol, identifies a token; converting raw values with decimals (6 vs 18) and storing 256-bit integers safely; mint and burn conventions (zero address) and why WETH Deposit/Withdrawal and dead addresses break them; approve, transferFrom and Approval, including unlimited approvals and permit; distinguishing ERC-20 and ERC-721 Transfers by topic count; fee-on-transfer and rebasing tokens and reconciling against balanceOf and totalSupply.