Lesson 03 · The Decoder Path

From a raw log to a typed event

Your EVM knowledge, made executable — and how to add a decoder (a real first contribution). ~10 min.

Builds on: Lesson 2 Anchor: topic0 = keccak(signature) New: Go interfaces Skill: add a decoder

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.

Where this sits in the pipeline
This is step ②'s first job (the indexer): block off the stream → decode → filter → write. The decoder's only output is a 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.

1 · topic0 is the routing key

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.

2 · The registry: a map, not a giant switch

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
    ...
}
Go concept: the LogDecoder interface
This is the one new Go idea. An interface is a contract — "any type with a Decode(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.goLogDecoder interface, Registry, NewRegistry, DecodeBlock.

3 · A real decoder, line by line

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 fieldWhy it's thereCode
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]
The gotcha that proves you understand EVM logs
ERC-20 and ERC-721 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.

4 · The skill: add a decoder (3 steps)

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:

Define the topic in pkg/decoder/topics.go:
TopicFooBar = crypto.Keccak256Hash([]byte("FooBar(address,uint256)"))
Write the decoder type in pkg/decoder/decoder.go — a struct + a 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
}
Register it in NewRegistry:
r.Register(TopicFooBar, &fooBarDecoder{})
…then it needs a home in the graph
A decoder only produces a 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).

Check yourself

1. What is the map key the registry uses to find the right decoder for a log?
2. In the transferDecoder, why is value read from l.Data while from/to come from l.Topics?
3. An ERC-721 Transfer hits the transferDecoder. What happens, and why?
4. A type satisfies the LogDecoder interface when…
5. You wrote a new decoder type with a correct Decode method but the event is still ignored at runtime. The most likely cause?
↳ Ask your teacher
Try: "Open a more complex decoder, like aaveBorrowDecoder," · "What does DecodedEvent / TransferEvent look like in types?" · "Show me a real handler that consumes a DecodedEvent," · "Walk me through adding a decoder for a real event I name."

What you can now do

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.