Your EVM knowledge, made executable — and how to add a decoder (a real first contribution). ~10 min.
You already know that every event log starts with topic0 — the
keccak256 hash of the event's signature. That single fact is the decoder path. The
indexer keeps a hash-map from topic0 → a small object that knows how to parse that
one event into a typed struct. This lesson shows you the real map, the real parser, and the
3-step recipe to add your own.
DecodedEvent struct. Turning that into graph edges (HOLDS etc.) is a
separate handler step — that's the next lesson. Keep the boundary crisp: decoders parse, handlers mutate.
In pkg/decoder/topics.go, every supported event's topic0 is
computed exactly the way you'd compute it by hand — there's no magic:
// pkg/decoder/topics.go var ( TopicTransfer = crypto.Keccak256Hash([]byte("Transfer(address,address,uint256)")) TopicApproval = crypto.Keccak256Hash([]byte("Approval(address,address,uint256)")) TopicRoleGranted = crypto.Keccak256Hash([]byte("RoleGranted(bytes32,address,address)")) // …~90 of these, across ERC-20, Aave, Compound, Uniswap, Morpho, Safe, Chainlink… )
Source: risk-graph-indexer/pkg/decoder/topics.go. This is the cast keccak "Transfer(address,address,uint256)" you've run a hundred times — just in Go.
The system avoids a 90-case switch. Instead, a Registry holds a Go map
from each topic0 to a decoder object:
// pkg/decoder/decoder.go type Registry struct { decoders map[common.Hash]LogDecoder // topic0 → decoder chainID types.ChainID } func NewRegistry(chainID types.ChainID) *Registry { r := ... r.Register(TopicTransfer, &transferDecoder{}) // wire each topic… r.Register(TopicApproval, &approvalDecoder{}) r.Register(TopicRoleGranted, &roleGrantedDecoder{}) // …one Register call per event type return r }
And the dispatch loop — for every log in the block, look up topic0, and if there's a
decoder for it, run it:
// pkg/decoder/decoder.go — DecodeBlock (simplified) for _, l := range tx.Logs { dec, ok := r.decoders[l.Topics[0]] // ← the whole routing decision if !ok { continue // no decoder for this event → ignore it } ev, err := dec.Decode(l, tx) // parse into a typed event ... }
LogDecoder interfaceDecode(log, tx) method counts as a LogDecoder." The map can hold many different
decoder types because they all satisfy that one contract:
type LogDecoder interface { Decode(log ethtypes.Log, tx *types.RawTransaction) (*types.DecodedEvent, error) }
&transferDecoder{} is just an empty struct whose only job is to have that method.
This is Go's version of polymorphism — no inheritance, just "do you have the method?"
Source: pkg/decoder/decoder.go — LogDecoder interface, Registry, NewRegistry, DecodeBlock.
Here's the actual transferDecoder — the whole thing. You'll recognise every byte of it:
// pkg/decoder/decoder.go type transferDecoder struct{} // no state — just a method holder func (d *transferDecoder) Decode(l ethtypes.Log, tx *types.RawTransaction) (*types.DecodedEvent, error) { // ERC-721 transfers share this topic0 but put tokenId in topics[3] and carry no data. // We only want ERC-20 → skip anything that doesn't look like one. if len(l.Topics) < 3 || len(l.Data) < 32 { return nil, errSkipEvent } return &types.DecodedEvent{ Type: types.EventTransfer, TxHash: tx.Hash, LogIndex: l.Index, Contract: l.Address, // the token contract that emitted it Payload: &types.TransferEvent{ Token: l.Address, From: common.BytesToAddress(l.Topics[1].Bytes()), // indexed arg #1 To: common.BytesToAddress(l.Topics[2].Bytes()), // indexed arg #2 Value: new(big.Int).SetBytes(l.Data[:32]), // non-indexed → from data }, }, nil }
Map it to what you know about ERC-20 Transfer(address indexed from, address indexed to, uint256 value):
| Log field | Why it's there | Code |
|---|---|---|
Topics[0] | The signature hash — already used for routing, not re-read here. | (the map key) |
Topics[1] | from is indexed → lives in a topic. | Topics[1] |
Topics[2] | to is indexed → lives in a topic. | Topics[2] |
Data[:32] | value is not indexed → lives in the data blob, first 32 bytes. | Data[:32] |
Transfer have the identical topic0 (same signature
string), but ERC-721's tokenId is indexed — so it has 4 topics and empty data,
while ERC-20 has 3 topics and a 32-byte data value. The len(l.Topics) < 3 || len(l.Data) < 32
guard is how this decoder tells them apart and skips NFTs. Miss this and you'd mis-decode every NFT transfer as a token balance.
Adding support for a new event is one of the most common first contributions, and now it's just
a recipe. Say you want to decode some FooBar(address indexed who, uint256 amount) event:
TopicFooBar = crypto.Keccak256Hash([]byte("FooBar(address,uint256)"))
Decode method that satisfies LogDecoder:
type fooBarDecoder struct{} func (d *fooBarDecoder) Decode(l ethtypes.Log, tx *types.RawTransaction) (*types.DecodedEvent, error) { if len(l.Topics) < 2 || len(l.Data) < 32 { return nil, errSkipEvent } return &types.DecodedEvent{ /* Type, Contract, Payload… */ }, nil }
NewRegistry:
r.Register(TopicFooBar, &fooBarDecoder{})
DecodedEvent. For it to change the graph you'll also add a
handler (in pkg/indexer/) that turns the typed event into edge mutations,
a payload struct in pkg/types/event_payloads.go, and a
test (the repo is test-first). That handler half is Lesson 4. And remember the process
rules from the glossary: a FORTA-XXXX Linear ticket before
you start, make test && make lint before every commit.
The decoder pattern + this recipe are distilled in the Adding-a-Decoder recipe card. Real registrations to copy: pkg/decoder/decoder.go NewRegistry (20+ examples).
transferDecoder, why is value read from l.Data while from/to come from l.Topics?Transfer hits the transferDecoder. What happens, and why?LogDecoder interface when…Decode method but the event is still ignored at runtime. The most likely cause?topic0 → registry map → a LogDecoder → a typed DecodedEvent.Topics[]/Data[] reads to indexed vs non-indexed args.Grounded in: pkg/decoder/topics.go (topic0 = Keccak256Hash of signature),
pkg/decoder/decoder.go (LogDecoder, Registry, NewRegistry, DecodeBlock, transferDecoder),
pkg/types/ (DecodedEvent, TransferEvent). Verify against source — the code is the truth.