Advanced level
An EVM transaction is a signed request: a target, an ETH value, gas limits and input data whose first four bytes choose the function and whose remaining 32 byte words carry the arguments. What actually happened lives in the receipt, as event logs whose first topic identifies the event, and in the trace, which records every internal call. Read all three layers and no explorer summary can mislead you.
Three layers: request, receipt and trace
A block explorer blends three different objects into one page. Pulling them apart is the first analyst habit, because each answers a different question and each can hide something the others reveal.
| Layer | What it is | What it answers | What it misses |
|---|---|---|---|
| Transaction | The signed request: nonce, gas fields, target, ETH value, input data | What the sender asked for | Whether it worked, and what it touched |
| Receipt | Produced by execution: status, gas used, event logs | Did it succeed, and what did the contracts announce | Anything a contract did without emitting an event, including plain ETH movements |
| Trace | A replay of every call inside the transaction, with values and inputs | The full call tree, internal ETH transfers, the exact frame that reverted | Little; the catch is that only archive or tracing nodes can produce one |
The rest of this guide takes one real transaction apart: a sale of 20 USDC for ETH through the Uniswap V2 router, mined in late September 2026, small enough to check by hand. The trader’s address has been swapped for an obvious placeholder and the hash shortened, because the lesson is the structure, not the person who sent it. If you have read the beginner guide to block explorers, this is the same page with the lid off.
The envelope: nonce, gas and value
hash 0x70b8...ba3f (shortened, see below)
block a mainnet block in late September 2026
type 2 (EIP-1559)
nonce (the sender's transaction count)
to 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D (Uniswap V2 Router 02)
value 0
gas limit 250,000
maxFeePerGas 9.296196401 gwei
maxPriorityFeePerGas 1 wei
input 260 bytes, starting 0x18cbafe5
receipt:
status 1 (success)
gasUsed 114,443
block base fee 4.594749928 gwei
effectiveGasPrice 4.594749929 gwei
logs 5Three fields repay close attention. The nonce is the sender’s sequence number; a pending
transaction with a lower nonce blocks this one, and resending the same nonce with a higher fee is how wallets
speed up or cancel. Value is ETH only, so it is zero here even though 20 USDC moved: the token
amounts travel in the input data. And the signed payload has no from field at all. The sender is
recovered from the signature, which is why nobody can forge a transaction for an address whose key they do not
hold.
The fee follows the EIP-1559 rule:
The sender offered a priority fee of 1 wei, so the effective price was the base fee plus 1 wei. The total was 114,443 gas × 4.594749929 gwei = 0.000525837 ETH, about 1.42 dollars at the pool’s own price, or roughly 7% of this small trade. All of it was burned except 114,443 wei, the tip. The 9.30 gwei cap was headroom in case the base fee rose; unused headroom is never charged. The type field says which rules apply:
| Type | Introduced by | What is different |
|---|---|---|
| 0 | The original format | A single gasPrice |
| 1 | EIP-2930 | Adds an access list of addresses and storage slots |
| 2 | EIP-1559 | A burned base fee plus a priority fee, with maxFeePerGas as the cap |
| 3 | EIP-4844 (Dencun, March 2024) | Carries blobs; this is how rollups post data |
| 4 | EIP-7702 (Pectra, May 2025) | An authorization list that points an ordinary address at contract code |
Function selectors: the first four bytes
The input data opens with four bytes that choose the function. They are the first four bytes of the Keccak-256
hash of the function’s canonical signature: its name and parameter types, with no spaces, no parameter names,
and aliases written out in full, so uint becomes uint256.
Some selectors are worth recognizing on sight:
| Selector | Signature | What it usually means |
|---|---|---|
0xa9059cbb | transfer(address,uint256) | An ERC-20 transfer from the sender |
0x095ea7b3 | approve(address,uint256) | Sets an allowance; read it before you sign it |
0x23b872dd | transferFrom(address,address,uint256) | A spender using an allowance |
0xa22cb465 | setApprovalForAll(address,bool) | An operator approved for every NFT in a collection |
0xd505accf | permit(...) (ERC-2612) | A signed approval being submitted onchain |
0x3593564c | execute(bytes,bytes[],uint256) | Uniswap Universal Router, and other contracts that copied its interface |
0x6a761202 | execTransaction(...) | A Safe multisig executing a transaction its owners signed |
0xa9059cbb, including workMyDirefulOwner(uint256,uint256) and many_msg_babbage(bytes1), and every one of them hashes to the same four bytes as transfer(address,uint256). A signature database gives you candidates. Only the verified source or ABI of the contract in front of you tells you which function actually runs, and a hostile contract is free to put dangerous code behind a harmless looking name.execTransaction whose operation argument was 1, a delegatecall, instead of 0, a plain call. The contract it called overwrote storage slot 0 of the wallet proxy, where the Safe keeps the address of its logic contract. The function name looked routine; the arguments were the attack. The full story is in what went wrong at Bybit.ABI decoding by hand
After the selector, arguments are laid out in 32 byte words. Static types such as uint256,
address and bool sit in place, left padded with zeros, so an address is 12 zero bytes
followed by its 20 bytes. Dynamic types such as arrays, bytes and string put an
offset in the head that points to a tail section, where the length comes first and the contents follow.
Offsets count from the start of the arguments, after the selector. Here is the whole input, one word per
line:
0x18cbafe5 selector
0x00 0000000000000000000000000000000000000000000000000000000001312d00 amountIn 20,000,000 = 20 USDC (6 decimals)
0x20 0000000000000000000000000000000000000000000000000018fc1e39b55b88 amountOutMin 7,032,606,188,460,936 wei
0x40 00000000000000000000000000000000000000000000000000000000000000a0 path offset: the array starts at 0xa0
0x60 000000000000000000000000a11ce000000000000000000000000000000a11ce to the recipient
0x80 000000000000000000000000000000000000000000000000000000006ab93ca0 deadline 1,790,524,576 (Unix time)
0xa0 0000000000000000000000000000000000000000000000000000000000000002 path.length 2
0xc0 000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 path[0] USDC
0xe0 000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 path[1] WETHWord 0x40 holds 0xa0, 160: the path array starts 160 bytes in, at the word that holds
its length, 2, followed by USDC and WETH. The deadline is a Unix timestamp for 15:56:16 UTC, 19 minutes and 41
seconds after the block. The minimum output was 0.0070326 ETH and the actual output was 0.0074031 ETH, so the
sender accepted a result 5.0% worse than they got: a 5% slippage setting, which the guide to
getting a good fill explains how to choose.
# Decode the calldata above with the standard library alone.
data = bytes.fromhex(calldata[2:]) # calldata: the full input as hex
args = data[4:] # offsets count from after the selector
def uint(at):
return int.from_bytes(args[at:at + 32], 'big')
def address(at): # the last 20 bytes of the word
return '0x' + args[at + 12:at + 32].hex()
amount_in, min_out = uint(0x00), uint(0x20)
path_at = uint(0x40) # 160, a pointer into the tail
recipient = address(0x60)
deadline = uint(0x80)
path = [address(path_at + 32 * (i + 1)) for i in range(uint(path_at))]cast 4byte 0x18cbafe5 # candidate signatures from a public database
cast 4byte-calldata 0x18cbafe5000... # decode calldata using that database
cast decode-calldata "swapExactTokensForETH(uint256,uint256,address[],address,uint256)" 0x18cbafe5000...
cast run <tx hash> --rpc-url $ETH_RPC_URL # replay with a call traceEvent logs: what the contracts announced
A log has three parts: the address of the contract that emitted it, up to four 32 byte topics, and a data blob.
For an ordinary event, topic0 is the full Keccak-256 hash of the event signature (all 32 bytes, not four).
Parameters marked indexed fill topics 1 to 3, so an event can index at most three. Everything else is
ABI encoded into the data. An indexed string, array or struct is stored as its hash, which lets you filter on it
but never read it back. These are the topics you will meet most:
Transfer(address,address,uint256)
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef ERC-20 and ERC-721 transfers
Approval(address,address,uint256)
0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925 allowances
Swap(address,uint256,uint256,uint256,uint256,address)
0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822 Uniswap V2 style pairs
Sync(uint112,uint112)
0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1 V2 reserves after every change
Swap(address,address,int256,int256,uint160,uint128,int24)
0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67 Uniswap V3 pools
Withdrawal(address,uint256)
0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65 WETH unwrapped to ETHOur swap produced five logs, decoded here:
#0 USDC Transfer from sender to pair value 20000000 (20 USDC)
#1 WETH Transfer from pair to router value 7403119027386028 (0.007403119 WETH)
#2 pair Sync reserve0 10493604956746 (10,493,604.96 USDC)
reserve1 3895950775264699295616 (3,895.95 WETH)
#3 pair Swap sender router, to router
amount0In 20000000, amount1In 0, amount0Out 0, amount1Out 7403119027386028
#4 WETH Withdrawal src router, wad 7403119027386028In order, they tell the story. The sender’s USDC went to the pair (log 0). The pair sent WETH to the
router, not to the sender (log 1). Sync reports the reserves after the trade, which puts the
pool’s price at 10,493,604.96 ÷ 3,895.95 = 2,693.46 USDC per ETH. Swap gives the same
amounts from the pair’s point of view. USDC is token0 because V2 pairs order their tokens by
address, and 0xa0b8 sorts before 0xc02a. Finally the router unwrapped the WETH (log 4).
The sender paid 20 ÷ 0.0074031 = 2,701.56 USDC per ETH, 0.30% above the pool price: almost exactly the pool’s fee, because 20 USDC barely moves a pool holding 10.5 million. Put the reserves before the trade into the constant product formula from AMM math and you reproduce the output to the last wei.
Two details catch people out. The USDC log is emitted from the USDC proxy address, even though the code that
ran lives in its implementation, because a delegatecall executes in the caller’s context. And ERC-721 NFTs use
the very same Transfer topic, but index the token ID as well, so an NFT transfer has four topics and
empty data while an ERC-20 transfer has three topics and the amount in data.
Internal calls and traces
Look again at the logs: nothing says the sender received any ETH. Plain ETH transfers emit no event. The router
unwrapped WETH and then sent ETH onward with a call that carried value, and explorers display that as an "internal
transaction". It is not a transaction at all, just a message call inside this one, and it can only be found by
re-executing the transaction with a tracer (debug_traceTransaction with a call tracer, or
trace_transaction on clients that support it). Simplified, the call tree looks like this:
CALL sender -> Router.swapExactTokensForETH(...)
STATICCALL Router -> Pair.getReserves() quote the output
CALL Router -> USDC.transferFrom(sender, Pair, 20 USDC)
DELEGATECALL USDC proxy -> USDC implementation log #0 is emitted here
CALL Router -> Pair.swap(0, 0.007403119 WETH, Router, "")
CALL Pair -> WETH.transfer(Router, 0.007403119) log #1
STATICCALL Pair -> USDC.balanceOf(Pair)
STATICCALL Pair -> WETH.balanceOf(Pair) logs #2 and #3 follow
CALL Router -> WETH.withdraw(0.007403119) log #4
CALL WETH -> Router value 0.007403119 ETH
CALL Router -> sender value 0.007403119 ETH the "internal transaction"Only a trace shows ETH moving between contracts, calls that reverted inside a try block while the
transaction succeeded, delegatecall hops through proxies, and the frame where a failure began. Tracing needs an
archive or tracing node, which is why explorers and data platforms precompute traces. What each of those calls
costs is the subject of the EVM internals guide.
Reading a failed transaction
A failed transaction has status 0. Every state change is rolled back and every log discarded, but the gas used is still paid and the nonce is still consumed, because the network did the work of running it. The reason is in the revert data, which follows one of four patterns:
| Revert data starts with | Meaning | How to read it |
|---|---|---|
0x08c379a0 | Error(string) from require or revert with a message | ABI decode one string |
0x4e487b71 | Panic(uint256) from a compiler check | 0x01 failed assert, 0x11 overflow or underflow, 0x12 division by zero, 0x32 array index out of bounds |
| Any other 4 bytes | A custom error (Solidity 0.8.4 and later) | The selector of the error signature; decode against the ABI or with cast decode-error |
| Nothing at all | A bare require, or the transaction ran out of gas | If gas used equals the gas limit, it was out of gas |
Had the price moved more than 5% before our swap was included, the router would have reverted with the message below. It is ordinary ABI encoding: a selector, an offset, a length and the padded bytes of the string.
Error(string) revert data for 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT'
08c379a0 selector of Error(string)
0000000000000000000000000000000000000000000000000000000000000020 offset of the string: 32
000000000000000000000000000000000000000000000000000000000000002b length: 43 bytes
556e69737761705632526f757465723a20494e53554646494349454e545f4f55 "UniswapV2Router: INSUFFICIENT_OU"
545055545f414d4f554e54000000000000000000000000000000000000000000 "TPUT_AMOUNT", padded with zerosIn a deep call tree, ask where the revert started. Outer contracts often pass the inner error up unchanged but some replace it, so the trace, not the top level message, tells you which contract refused.
The same questions on Solana
A Solana transaction is a list of instructions, each naming a program, every account it will read or write, and an opaque data field. All instructions succeed or none do. There is no calldata standard like the EVM ABI and no topics: programs write log lines, and when one program calls another (a cross program invocation) the call is recorded as an inner instruction. Here are the logs of a real Orca Whirlpool swap from September 2026, trimmed:
Program ComputeBudget111111111111111111111111111111 invoke [1]
Program ComputeBudget111111111111111111111111111111 success
Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [1]
Program log: Instruction: SwapV2
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 105 of 13472 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 112 of 10675 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program data: 4cpJr5MroJazrJmfc0yJa40v9BRk1Et7LAO/DzpiUkK2Wtn0Pd1oxgBCsIATwyy2tgAA...
Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc consumed 31093 of 38809 compute units
Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc successThe number in brackets is the call depth: [1] for top level instructions, [2] for the
two Token program transfers the Whirlpool program invoked, one from the user into the pool and one back out. Programs built with the
Anchor framework start their instruction data with an 8 byte discriminator, the first 8 bytes of
sha256("global:<instruction name>"); for this swap that is 2b04ed0b1ac91e62,
sha256("global:swap_v2"). Anchor events arrive as base64 Program data: lines with the
same trick, and this one decodes to e1ca49af932ba096, sha256("event:Traded"), followed by
the trade details. Non-Anchor programs pick their own layouts: the Compute Budget program uses a single leading byte,
0x02 to set a compute unit limit and 0x03 to set a price.
The fee is 5,000 lamports per signature plus an optional priority fee: the compute unit limit times a price in micro-lamports per unit. This swap set its price to zero and paid exactly 5,000 lamports. For "who received what", read the pre and post token balances in the transaction metadata, which is where explorers such as Solscan and Solana Explorer build their balance change tables. Versioned transactions can also load accounts from address lookup tables, so an explorer may list more accounts than the signed message does.
Tools, and a routine that works on anything
Explorers
Etherscan style explorers decode input for verified contracts, list logs and internal transactions, and label known addresses. Every panel is their interpretation of the raw data.
Debuggers and simulators
Tenderly replays a transaction step by step and lets you re-simulate it with changed inputs. BlockSec Phalcon Explorer shows the call tree beside a fund flow view built for incident analysis.
Signature databases
4byte.directory and Sourcify’s 4byte.sourcify.dev, which took over the openchain.xyz database, map selectors and topics back to text signatures. Treat every answer as a candidate.
Your own tools
Foundry’s cast decodes calldata, looks up selectors and replays a transaction with a full trace. A few lines of Python cover the rest.
Status and fee first
Did it succeed, how much gas did it use against its limit, and what was the effective gas price? A failure that used every unit of gas is a different problem from one that stopped early.
Identify the target
Is
toa contract, and is it a proxy? If it is, the logic lives at the implementation address, and that is the code you need to read. The guide to admin keys and upgradeability shows how to find it.Decode the input against the verified ABI
Use the contract’s own ABI, not a database guess. Check every argument that can move value: amounts, recipients, deadlines, and any nested call data.
Read the logs in order
Follow the Transfer events to see who ended up with what, and check that they agree with the Swap or protocol events around them.
Check the trace for what the logs leave out
ETH movements, calls that failed inside a
try, and the exact frame where a revert began.
To find the right explorer for any address or hash, the explorer router sorts it by format. The specifications are short and worth one careful read: the Solidity ABI specification, EIP-1559 for the fee fields, and Solana’s transaction documentation.
Common questions
What is the difference between a transaction and an internal transaction?
A transaction is signed by an externally owned account and included in a block. An internal transaction is a label explorers use for a message call made by a contract during that transaction, usually one that moved ETH. It has no signature, no nonce and no hash of its own, and you only see it in a trace.
Why does a failed transaction still cost gas?
Every node had to execute it to discover that it failed, and that work is what gas pays for. The state changes and logs are discarded, but the gas used is still charged and the nonce is still consumed. Simulating before you send is the cheap way to find a revert.
Can I trust the decoded input an explorer shows me?
For a verified contract, the explorer decodes with the contract's own ABI, which is reliable. For an unverified contract it may guess from a signature database, and selectors collide. Check that the target is verified and, for a proxy, that you are reading the implementation's ABI.
Do layer 2 transactions read the same way?
On EVM rollups the transaction format, the ABI, logs and traces work as they do on Ethereum. The fee differs: it adds a charge for publishing the data to Ethereum, which most layer 2 explorers show separately.
Where to go next
Next: Following the money: flows and clustering
Guide 14 of 16 in the Advanced level. Your place is kept on this device, so you can pick up exactly here next time.