You've fetched some logs — maybe with eth_getLogs, maybe from an eth_subscribe stream, maybe out of a transaction receipt. What you get back isn't a friendly Transfer(from, to, value) object. It's this:
{
"address": "0xA0b8...eB48",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045",
"0x0000000000000000000000005aae...c0de"
],
"data": "0x00000000000000000000000000000000000000000000000000000000000f4240",
"blockNumber": "0x...", "logIndex": "0x...", "transactionHash": "0x..."
}
To turn that into "0xd8dA… sent 1,000,000 units to 0x5aAe…", you need to understand how events are encoded. Once you do, decoding is mechanical. Here's the map.
Anatomy of a log: address, topics, data
Every log has three parts that matter for decoding:
-
address— the contract that emitted the event. -
topics— an array of up to 4 32-byte values. The first is special; the rest are indexed parameters. -
data— a blob of ABI-encoded, non-indexed parameters concatenated together.
The split between topics and data is the whole game, and it comes down to one keyword in the Solidity event: indexed.
topic0 is the event signature hash
Take a standard ERC-20 event:
event Transfer(address indexed from, address indexed to, uint256 value);
topics[0] is the keccak256 hash of the event signature string — the canonical form Transfer(address,address,uint256) (event name, then the parameter types in parentheses — no spaces, no parameter names). For Transfer, that hash is always:
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
That's why topics[0] is how you identify which event a log is: it's a fixed fingerprint. When you filter logs by event type, you're filtering on this value. You can compute it yourself:
import { keccak256, toHex } from "viem";
keccak256(toHex("Transfer(address,address,uint256)"));
// 0xddf252ad...523b3ef — matches topics[0]
indexed params go in topics; everything else goes in data
Here's the rule that determines where each parameter lands:
-
indexedparameters become entries intopics[1],topics[2],topics[3](in declaration order). A max of 3 indexed parameters are allowed (4 topics total, minus topic0). -
Non-indexed parameters are ABI-encoded together into
data.
So for Transfer(address indexed from, address indexed to, uint256 value):
-
topics[0]= the signature hash -
topics[1]=from(indexed) -
topics[2]=to(indexed) -
data=value(not indexed)
Why the split matters to you: indexed parameters are searchable. Because they're topics, you can ask the node "give me every Transfer where to = my address" by filtering on topics[0] and topics[2] — the node does the matching. Non-indexed fields in data are not filterable; you get them only by decoding logs you already fetched. That's a design decision made when the contract was written, and it shapes what queries are cheap.
Let the library decode it
You almost never hand-decode. Give a decoder the event's ABI and it does the topic/data split for you:
// viem
import { decodeEventLog } from "viem";
const decoded = decodeEventLog({
abi: erc20Abi,
data: log.data,
topics: log.topics,
});
// { eventName: "Transfer", args: { from, to, value } }
// ethers v6
import { Interface } from "ethers";
const iface = new Interface(erc20Abi);
const parsed = iface.parseLog({ topics: log.topics, data: log.data });
// parsed.name === "Transfer"; parsed.args.from / .to / .value
# web3.py
contract = w3.eth.contract(abi=ERC20_ABI)
ev = contract.events.Transfer().process_log(log)
# ev["args"]["from"], ev["args"]["to"], ev["args"]["value"]
viem also has parseEventLogs({ abi, logs }) to decode a whole receipt's worth of logs and drop the ones that don't match your ABI — handy for indexing.
The traps that produce wrong or missing values
Decoding is mechanical until you hit one of these. Each one silently gives you bad data if you're hand-rolling:
1. Indexed dynamic types give you a hash, not the value. If a string, bytes, or array parameter is indexed, the topic doesn't contain the value — it contains the keccak256 hash of the value (a 32-byte slot can't hold arbitrary-length data). So event Named(string indexed name) lets you filter by a known name (hash it and match), but you cannot recover the original string from the log. If you need the readable value, it has to be non-indexed (in data) or duplicated in another field. This surprises people constantly.
2. Address topics are left-padded to 32 bytes. An address is 20 bytes, but topics are 32. So from shows up as 0x000000000000000000000000d8da6bf2… — twelve zero bytes then the address. Libraries strip the padding for you; if you're slicing bytes by hand, take the last 20 bytes, not the first.
3. data is positional ABI encoding. Multiple non-indexed params are packed in declaration order, 32 bytes each (with dynamic types using offset+length). You must decode against the correct ABI types in the correct order — there are no field names in the bytes. Wrong ABI, wrong values, no error.
4. Anonymous events have no topic0. An event declared anonymous omits the signature hash from topics, freeing a 4th indexed slot but making the event un-identifiable by topics[0]. Rare, but if a log has no recognizable topic0, this may be why.
5. Same event name, different signatures. topics[0] hashes the full signature including types. Transfer(address,address,uint256) (ERC-20) and Transfer(address,address,uint256) for an ERC-721 tokenId share the shape but ERC-721 marks tokenId as indexed — so the topic layout differs even though topic0 matches. Decode with the ABI that matches the contract you're reading, and don't assume every Transfer has the same indexed layout.
Keying decoded logs for a durable index
Once decoded, store logs so they survive reorgs. The unique identity of a log is (transactionHash, logIndex) (or (blockHash, logIndex)), not the block number — block numbers can be reorged out from under you. This is the same discipline as handling chain reorgs: key on hash, and re-fetch on rollback. And remember logs are how you track things that have no Transfer-style movement in balances too — see reading balances right for why events alone can under-count.
The short version
A raw log is address + topics + data. topics[0] is the keccak256 hash of the event's canonical signature (Transfer(address,address,uint256)) — the fingerprint that identifies the event and the thing you filter on. indexed parameters land in topics[1..3] (max 3, searchable); everything else is ABI-encoded in data (not searchable). Decode with a library (decodeEventLog / Interface.parseLog / process_log) against the right ABI — never hand-slice unless you must. Watch the traps: indexed dynamic types give you a hash, not the value; address topics are left-padded (take the last 20 bytes); data is positional; anonymous events lack topic0; and the same event name can have different indexed layouts. Then key decoded logs on (txHash, logIndex) so they survive reorgs.
Need a reliable endpoint to pull and decode logs across chains? A flat-rate Ethereum RPC endpoint — plus 75+ other chains under one key — gives you eth_getLogs, eth_getTransactionReceipt, and eth_subscribe over HTTP and WebSocket. Grab a free key and point your stack at:
https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY
Originally published on the SwiftNodes blog. SwiftNodes provides flat-rate multi-chain RPC endpoints — HTTP + WebSocket, 75+ chains, no per-request metering. Grab a free key.
Top comments (0)