Tracing and transaction graphs · Lesson 5 of 6
Account-model, token and NFT tracing
13 min read
On Ethereum and other account-model chains, much of the value in a typical fraud or theft never appears as a simple transfer between two addresses. It moves inside contract calls, as token events whose content the contract itself chose to emit, or through a signature the victim gave away days earlier. An examiner who traces only an explorer's "transactions" tab will miss hops, misstate who moved funds, and report figures the other side can disprove from the same chain, sometimes by a factor of a trillion.
Four places value moves
Each channel is recorded in a different place, so a hop record must say where its evidence came from.
| Channel | Where it is recorded | Forensic trap |
|---|---|---|
| Top-level value: native ETH sent by a transaction | The transaction itself | A failed transaction moves no value but still pays gas |
| Internal transfers: ETH sent by a contract during execution | Only in execution traces | Absent from receipts and logs; some explorers show it, some do not |
| Token transfers: ERC-20, ERC-721, ERC-1155 | Transfer events in the receipt logs | The event's contract address identifies the token, not its symbol |
| State changes without standard events: rebasing, fee-on-transfer, non-standard tokens | Contract storage | Event sums disagree with balances; reconcile with state queries at a block |
Internal transfers are the most common source of missed hops: a swap paying out native ETH, a forwarding contract or a batching wallet contract moves ETH through calls that leave no log. Obtaining them needs a tracing-capable node, usually an archive node, and method names differ between clients (debug_traceTransaction with a call tracer in some, trace_transaction in others). Record the client, version and method behind each trace, because two tracers can present the same execution differently (Lessons 2.4, 2.9 and 5.1 cover decoding).
Reading ERC-20 transfers
An ERC-20 token (EIP-20) is a contract holding a mapping from addresses to balances. When someone "sends USDC", the transaction's to is the token contract (or a router), its native value is usually 0, and the recipient appears only in calldata and logs. Balance changes emit Transfer(address indexed _from, address indexed _to, uint256 _value), with topic0 0xddf252ad…b3ef on every EVM chain, sender and recipient in topics 1 and 2, and the raw amount in data.
The token's identity is the log's emitting contract address, never its name or symbol: anyone can deploy a contract called "USDC" that emits perfectly formed Transfer events. Events are also written by the contract's own code, so a malicious contract can emit Transfer events that match no balance change. Filter or flag zero-value transfers and transfers of unlisted tokens 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; read it once per contract from decimals() and store it 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 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), so store them as strings or high-precision decimals and divide only at presentation.
Mints, burns and tokens that break the model
Mints are Transfers from the zero address and most burns are Transfers to it; sending tokens to a "dead" address such as 0x…dEaD is not a burn, because the balance still exists. Some tokens break the convention with custom Mint and Burn events, and the original WETH contract (WETH9) emits Deposit and Withdrawal with no Transfer, so a WETH balance rebuilt from Transfer logs alone is wrong for any address that wraps or unwraps.
The rule "balance = incoming minus outgoing Transfer values" also fails for:
| 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 in the Transfer to the recipient, not the calldata amount |
| Rebasing | Balances change without any Transfer event | Read balanceOf at block heights; model the rebase |
| Share-based | The contract tracks shares; displayed balances 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: 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. Its wrapper, wstETH, behaves like an ordinary ERC-20.
Before reporting token flows, reconcile: fix the token by contract address and chain ID; rebuild the relevant balances from Transfer logs up to the cut-off block; compare them with balanceOf, and mints minus burns with totalSupply(), at that block; and explain every difference or state it as a limitation. Token events are claims made by contract code; state queries test them.
Approvals, transferFrom and drainers
ERC-20 lets an owner authorize a spender with approve(spender, amount), which emits Approval(owner, spender, value). The spender, typically a DEX router or lending pool, later calls transferFrom(owner, recipient, amount), and in the resulting Transfer from is the owner whose tokens moved, not the account that sent the transaction. Unlimited approvals (2^256 − 1, 64 hex digits f) let the spender move the owner's entire balance at any time. Allowances cannot always be rebuilt from events (OpenZeppelin's widely copied library stopped emitting a new Approval when transferFrom spends an allowance in version 5.0), so read allowance(owner, spender) at the block of interest.
A drainer is a phishing kit that persuades a victim to grant a spending right and then uses it. The victim's act is one of:
- an on-chain approval transaction, often unlimited, signed and sent by the victim;
- an off-chain permit signature (EIP-2612), which the attacker submits and pays gas for; the specification lets any address submit a valid permit, and a successful one sets the allowance and emits an
Approvalevent in a transaction the victim never sent; - a signature for a shared approval contract that the victim had already approved, which then authorizes the transfer.
The drainer contract then calls transferFrom and splits the proceeds. A study by He and colleagues at Zhejiang University and Mohamed bin Zayed University of Artificial Intelligence (Unmasking the shadow economy: a deep dive into drainer-as-a-service phishing on Ethereum, 2025), covering March 2023 to April 2025, found the most common split was 20% to the kit operator and 80% to the affiliate who ran the phishing campaign. A fixed split repeated across many victims is a strong pattern, but it remains an inference about roles until other evidence confirms it.
Part of the evidence therefore sits off chain. Under lawful authority, the victim's device may hold the browser history of the phishing site and the wallet's record of signature requests, which together date the victim's act (Lesson 7.3). Off-chain signatures leave nothing on chain until used, so the gap between signature and drain can be long.
Worked trace: a permit drainer
A victim reports losing 25,000 USDC. The examiner decodes the relevant transactions from a tracing node:
tx 0x3b9e…a417 block 24,598,331 status 1 sender 0xD7c4…19
call 0xD7c4…19 -> 0x8f2A…c3 (contract, unverified)
log 41 token 0xA0b8…eB48 Approval owner 0x5e1C…2e spender 0x8f2A…c3 value 25000000000
log 42 token 0xA0b8…eB48 Transfer from 0x5e1C…2e to 0x0dE3…7a value 5000000000
log 43 token 0xA0b8…eB48 Transfer from 0x5e1C…2e to 0xAf10…d0 value 20000000000
tx 0x77c0…5b12 block 24,598,402 status 1 sender 0xAf10…d0
log 7 token 0xA0b8…eB48 Transfer from 0xAf10…d0 to 0x9c3B…e1 (pool) value 20000000000
log 9 token WETH Transfer from 0x9c3B…e1 to 0x4d1F…80 (router) value 7.9e18
trace [0,3] CALL 0x4d1F…80 -> 0xF5a9…31 value 7.9 ETH
tx 0x1e44…c9a0 block 24,598,460 status 1 sender 0xF5a9…31
top-level value 0xF5a9…31 -> 0x2bD1…f6 7.89 ETH
The first transaction was not sent by the victim. Its sender called an unverified contract, which submitted the victim's permit (log 41, an allowance of 25,000 USDC at 6 decimals) and moved 5,000 and 20,000 USDC out of the victim's address in the same transaction (logs 42 and 43). The amounts reconcile: 5,000 + 20,000 = 25,000, a 20/80 split. A report saying "the victim sent 25,000 USDC" would misdescribe an event the victim's key never signed. In the second transaction the larger recipient swapped its 20,000 USDC through a pool; the router unwrapped 7.9 WETH and sent 7.9 ETH to a fresh address. That hop appears only in the trace at position [0,3]: an examiner reading logs alone would lose the funds here. In the third transaction the fresh address sent 7.89 ETH, keeping about 0.01 ETH from which it paid gas, to 0x2bD1…f6, which forwards its deposits to an exchange hot wallet.
Every flow here is grade A: recorded on chain and verifiable from a node. The operator and affiliate roles are inferences from the split. The exchange deposit label is a single heuristic until the exchange confirms it, and from there the trace continues as described in Lesson 3.4.
Recording a contract-mediated hop
The hop record from Lesson 3.2 needs extra fields on account chains: the log index or trace position, because one transaction can contain dozens of transfers; the token contract address, its decimals and the raw integer value; the transaction sender as well as the transfer's from and to, because they are often different parties; and the transaction status, since reverted transactions leave traces but no lasting transfers.
Two identity traps are specific to tokens. Counterfeit tokens copy the name and symbol of a real stablecoin; only the contract address shows which is genuine. Address poisoning sends zero-value, dust or fake transfers from an address that resembles one the victim uses, hoping the victim copies it from history. Always compare full addresses, never the first and last characters.
Stablecoins and issuer-level visibility
Stablecoin transfers are ordinary token events and trace deterministically, with two added features. First, the major fiat-backed stablecoin contracts give the issuer a freeze (blocklist) function, so a balance that has not moved can sometimes be stopped faster than any court order (Lesson 10.1). Second, the issuer sees the same chain as everyone else and holds identity records only for its direct customers, typically those who mint and redeem: it can confirm which addresses it froze and who minted or redeemed, not who controls an ordinary holder's address. Record the chain and contract address for every stablecoin hop: the same ticker exists on many chains, and a bridged version is a separate contract that the original issuer's freeze may not reach. Large stablecoin flows on Tron are covered in Lesson 5.4.
NFTs and multi-token standards
An ERC-721 token gives each token ID exactly one owner. Its Transfer(address,address,uint256) has the same topic0 as ERC-20, because indexed is not part of the signature, but the token ID is indexed: an ERC-721 Transfer has four topics and empty data, an ERC-20 Transfer three topics with the amount in data. A decoder keyed only on topic0 reads token IDs as amounts. ApprovalForAll lets an operator, usually a marketplace contract, move every token the owner holds in a collection, and phishing that obtains it can empty a collection in one transaction: it is the first event to check when a victim reports stolen NFTs. To confirm what standard a contract claims, call supportsInterface (ERC-165): 0x80ac58cd for ERC-721, 0xd9b67a26 for ERC-1155, 0x2a55205a for the royalty standard ERC-2981. Early collections can predate the standards: CryptoPunks (2017) use custom events such as PunkTransfer and PunkBought, invisible to an ERC-721-only decoder.
ERC-1155 puts many token types in one contract, each with an id and a quantity per holder. Its TransferSingle (topic0 0xc3d58168…0f62) and TransferBatch (0x4a39dc06…f7fb) events carry an operator, the account making the transfer, so a marketplace sale shows the marketplace as operator and the seller as from. Mints and burns use the zero address. Batch events carry arrays of ids and values; one TransferBatch must become one row per id, or most of a batch-heavy contract's volume silently disappears.
Sales, royalties and wash flows
A Transfer event has no price: owners move NFTs between their own wallets, lend them, airdrop them and sell them for off-chain payments. A sale is an inference from a payment in the same transaction and, ideally, the marketplace's settlement event. Seaport, used by OpenSea and others, emits OrderFulfilled with an offer (what the order's creator gives) and a consideration (what the creator and fee recipients receive): in a listing sold for 10 ETH, the consideration pays 9.45 ETH to the seller, 0.05 ETH (0.5%) to a fee address and 0.5 ETH (5%) to the creator. When a seller accepts a bid, the offerer is the buyer, offering WETH. A bundle's per-item price is an allocation and should be labeled as one. ERC-2981 royalties are informational (OpenSea, for example, made creator fees optional for new collections from August 31, 2023), so measure royalties actually paid, not the advertised rate.
NFTs can disguise a value transfer as a sale: a launderer buys an NFT from a wallet they also control with illicit funds, and the proceeds look like trading income. The payment leg carries the value: follow the ETH or stablecoin paid to the seller and treat the NFT movement as context. Wash-trading signals include closed cycles (A → B → A), a common funder of buyer and seller, a common exit for proceeds, a zero-risk combined position, self-financing through flash loans or same-transaction payments, and prices far above the collection's range. CryptoPunk 9998 is the famous case: on October 28, 2021 it "sold" for about 124,457 ETH (around $532 million) when, as the collection's creators explained, the buyer bought it from itself with borrowed money repaid in the same transaction. A peer-reviewed study (La Morgia et al., IEEE ICDCS 2023) found wash trading made up about 85% of volume on LooksRare, which rewarded trading volume with tokens. A query for tokens bought back by their earlier seller yields leads, not findings; confidence rises when independent signals line up (Lesson 6.5).
Exam focus: the four channels where value moves on account chains and where each is recorded; why internal transfers need a tracing-capable node and why client, version and method are recorded; identifying tokens by contract address, converting with the right decimals, and the counterfeit-token and address-poisoning traps; mint and burn conventions and the tokens that break them (WETH, fee-on-transfer, rebasing), with reconciliation againstbalanceOfandtotalSupply; approve,transferFrom, EIP-2612 permit and drainer splits as an inference about roles; reading and reconciling a decoded drainer trace; extra hop-record fields; stablecoin freezes and the limits of issuer visibility; ERC-721 versus ERC-20 topics, ApprovalForAll, ERC-1155 operator and batches; that a transfer has no price; following the payment leg in NFT wash flows.
Practice questions
Check your understanding. These questions do not count towards the exam.
Sign in to save your progress and continue where you left off. It is free. Sign in