Token, NFT and smart contract flows · Lesson 3 of 6
Decoding smart contract interactions
8 min read
Every smart contract interaction arrives as bytes: a calldata string in the transaction and topics and data in the logs. Turning those bytes into "who called what, with which arguments, and what happened" is the core skill of account-model analysis. It depends on getting the right ABI, for the right code, at the right block, and on knowing where the hashes that label functions and events come from.
The ABI as a decoding key
The Application Binary Interface (ABI) is a JSON description of a contract's functions, events and errors: their names, parameter types, which event parameters are indexed, and return types. Compilers produce it from source code, and explorers publish it for verified contracts. With the ABI, a decoder knows that a given 4-byte prefix means transfer, that the next 32 bytes are an address and the following 32 bytes an amount.
Decoding uses the canonical signature: the name followed by the parameter types in parentheses, separated by commas, with no spaces and no parameter names. Aliases are expanded (uint becomes uint256) and structs are written as tuples, for example aggregate3((address,bool,bytes)[]). A single extra space or an unexpanded alias produces a completely different hash, which is the most common reason a hand-built decoder finds nothing.
Function selectors
A function selector is the first 4 bytes of the keccak-256 hash of the canonical function signature. keccak256("transfer(address,uint256)") begins 0xa9059cbb, so every call to an ERC-20 transfer starts with those 4 bytes. After the selector, arguments are ABI-encoded in 32-byte words: static types (address, uint256, bool) sit in place, left-padded; dynamic types (bytes, string, arrays) leave an offset in place and put their length and contents at the end.
a9059cbb selector: transfer(address,uint256)
000000000000000000000000b0b5e6f7…41aa arg 1: recipient address
0000000000000000000000000000000000000000000000000000000077359400 arg 2: 2,000,000,000 base units
This calldata sends 2,000,000,000 base units, 2,000 tokens if the token has 6 decimals, to the recipient. Note that the recipient is not the transaction's to field, which is the token contract.
Four bytes allow about 4.3 billion values, and selector collisions exist: burn(uint256) and collate_propagate_storage(bytes16) both hash to 0x42966c68. Open signature databases (such as the 4byte directory and the Sourcify/OpenChain signature lookup) map selectors back to text signatures, but they can return several candidates for one selector, and anyone can submit entries. Prefer the verified ABI of the contract you are decoding; use databases for unverified contracts, and choose between candidates by checking that the arguments decode cleanly. A selector the contract does not recognize is routed to its fallback function, so "unknown selector" calls can still do something.
The same scheme labels revert data, which explains why a failed transaction failed. A plain require message is encoded as Error(string), selector 0x08c379a0, followed by the ABI-encoded text; compiler-inserted checks such as arithmetic overflow use Panic(uint256), selector 0x4e487b71, with a numeric code; and contracts can define custom errors, whose selectors are derived exactly like function selectors and appear in the ABI. Decoding the revert reasons of an attacker's failed attempts before a successful exploit often shows what they were testing.
Event signatures and topics
Events use the full 32-byte hash of the canonical event signature as topic0. A non-anonymous event can have up to three indexed parameters in topic1 to topic3. Anonymous events omit topic0, which frees a fourth indexed slot but makes them invisible to filters that search by signature. Indexed parameters of dynamic type (string, bytes, arrays) are stored as the keccak-256 hash of the value, so the original value cannot be recovered from the topic.
A reference set of topic0 values, verified by hashing the signatures:
Transfer(address,address,uint256) 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
Approval(address,address,uint256) 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925
ApprovalForAll(address,address,bool) 0x17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31
TransferSingle(address,address,address,uint256,uint256) 0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62
TransferBatch(address,address,address,uint256[],uint256[]) 0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb
Swap(address,uint256,uint256,uint256,uint256,address) 0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822
Swap(address,address,int256,int256,uint160,uint128,int24) 0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67
Upgraded(address) 0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b
The two Swap lines are the Uniswap v2 pair and Uniswap v3 pool events (see the lesson DEX swaps and liquidity flows). Two lessons follow from the table. First, topic0 tells you the kind of event and the log's address tells you which contract emitted it; a filter on topic0 alone returns events from every contract that uses the signature, including fakes. Second, events with the same types but different indexing share a topic0, as ERC-20 and ERC-721 Transfer do, so check the number of topics before decoding.
Proxy contracts and implementation lookup
Many contracts are proxies: a small contract that holds the storage and the balances and forwards every call, with delegatecall, to an implementation contract that holds the logic. Upgrading means pointing the proxy to new logic. For an analyst this has three consequences:
- The ABI you need is the implementation's, not the proxy's.
- Logs are emitted with the proxy's address, because the code runs in the proxy's context. A token's identity stays the proxy address across upgrades.
- The implementation can change over time, so decode with the implementation that was active at the block of the transaction.
EIP-1967 standardized where proxies store these pointers, so tools can find them. Each slot is keccak-256 of a fixed string minus 1:
| Purpose | String hashed | Slot |
|---|---|---|
| Implementation | eip1967.proxy.implementation | 0x3608…2bbc |
| Admin | eip1967.proxy.admin | 0xb531…6103 |
| Beacon | eip1967.proxy.beacon | 0xa3f0…3d50 |
Reading the implementation slot at a given block is one JSON-RPC call:
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getStorageAt",
"params": [
"0x9c4e…1d27",
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"0x1406f40"
]
}
The response is one 32-byte word, for example 0x000000000000000000000000e5a1…7c30; the last 20 bytes are the implementation address at block 21,000,000. Historical reads need an archive node (see the lesson Node APIs and data access). The proxy also emits Upgraded(address indexed implementation) on each change, so pulling those logs gives the full upgrade timeline:
{
"address": "0x9c4e…1d27",
"topics": [
"0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b",
"0x000000000000000000000000e5a1…7c30"
],
"data": "0x",
"blockNumber": "0x13d6200",
"decoded": {"event": "Upgraded", "proxy": "0x9c4e…1d27", "implementation": "0xe5a1…7c30", "block": 20800000}
}
The log's address is the proxy, and its only indexed parameter is the new implementation. Here the implementation read at block 21,000,000 was installed at block 20,800,000; transactions before that block must be decoded with the previous implementation's ABI.
Other patterns need other lookups:
- Transparent and UUPS proxies (EIP-1822 style, as implemented by OpenZeppelin) both use the EIP-1967 implementation slot; they differ in where the upgrade function lives.
- Beacon proxies store a beacon address; call
implementation()on the beacon. - Minimal proxies (EIP-1167 clones) hard-code the implementation address in their 45-byte runtime bytecode, between the prefix
363d3d373d3d3d363d73and the suffix5af43d82803e903d91602b57fd5bf3. - Safe multisig proxies keep their implementation (the singleton, historically called
masterCopy) in storage slot 0, not in an EIP-1967 slot. The Bybit theft of February 2025 overwrote exactly that slot (see the lesson Analyzing exploits and hacks). - Diamonds (EIP-2535) route each selector to a different facet contract; you need the selector-to-facet table at the block.
Verified source code
An explorer or the open Sourcify repository marks a contract as verified when compiling the published source with the stated settings reproduces the deployed bytecode. Sourcify distinguishes a full (exact) match, where even the embedded metadata hash matches, from a partial match, where only the executable code does. Verification proves that the source corresponds to the bytecode. It does not prove that the code is safe, audited or honest, and the contract name in verified source is chosen by the deployer. For unverified contracts, analysts combine selector lookups, decompiler output and observed behavior in traces, and lower their confidence accordingly.
Multicall and batched calls
Batching hides actions behind one outer call. Common forms:
- Multicall contracts, such as Multicall3's
aggregate3((address,bool,bytes)[])(selector 0x82ad56cb), which executes a list of calls. - Router
multicall(bytes[])(0xac9650d8) andmulticall(uint256,bytes[])(0x5ae401dc, with a deadline), where each element is calldata for another function of the same router. - Multisig batches (Safe's MultiSend) and smart-account batches (ERC-4337
handleOps, EIP-7702 delegated accounts).
The outer selector says only "batch". Decode recursively: decode the outer call, take each inner bytes element, read its selector and decode it with the target's ABI. Then confirm against the trace, the call tree that shows each inner call's target, value and success, because a batch may allow individual calls to fail. Logs from all inner calls appear in the one receipt, ordered by logIndex, which ties them back to the steps. The lesson Querying EVM logs and traces shows how to do this at scale.
Exam focus: canonical signatures (no spaces, uint256, tuples); deriving a function selector (first 4 bytes of keccak-256, e.g. 0xa9059cbb for transfer) and reading calldata words; selector collisions and how to choose among database candidates; topic0 as the full event-signature hash, indexed limits, anonymous events and hashed dynamic topics; proxies: logs carry the proxy address, the EIP-1967 implementation slot (keccak-256 of "eip1967.proxy.implementation" minus 1), eth_getStorageAt at a block, Upgraded events, beacon, minimal and Safe slot-0 patterns; what verification does and does not prove; decoding multicall batches recursively and confirming with traces.
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