Reference · Recipe Card

Adding an event decoder

The repeatable path from "this event isn't tracked" to a typed DecodedEvent. Companion to Lesson 3.

Mental model (one line)

topic0 (= keccak256 of the signature) is a map key → a LogDecoder object → a typed DecodedEvent. Decoders parse; handlers mutate the graph.

The 3 core steps

  1. Define the topicpkg/decoder/topics.go:
    TopicFooBar = crypto.Keccak256Hash([]byte("FooBar(address,uint256)"))
    The signature string must be the canonical form: no spaces, no arg names, tuples as (t1,t2). Verify with cast keccak "FooBar(address,uint256)".
  2. Write the decoderpkg/decoder/decoder.go:
    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 {   // shape guard — see gotchas
            return nil, errSkipEvent
        }
        return &types.DecodedEvent{
            Type:     types.EventFooBar,
            TxHash:   tx.Hash, LogIndex: l.Index, Contract: l.Address,
            Payload:  &types.FooBarEvent{
                Who:    common.BytesToAddress(l.Topics[1].Bytes()),  // indexed → topic
                Amount: new(big.Int).SetBytes(l.Data[:32]),         // non-indexed → data
            },
        }, nil
    }
  3. Register it — in NewRegistry (pkg/decoder/decoder.go):
    r.Register(TopicFooBar, &fooBarDecoder{})
    Forgetting this is the #1 "my decoder does nothing" bug — the type is fine but it's not in the map.

To actually change the graph (the other half)

Also addWhereWhy
Payload struct (FooBarEvent) + EventFooBar typepkg/types/event_payloads.go, events.goThe typed shape the decoder emits and the handler consumes.
A handler that turns the event into edge mutationspkg/indexer/Decoders don't write to Memgraph — handlers do (Lesson 4).
Testsalongside, *_test.goRepo is test-first; a decoder PR without a decode test won't pass review.

Field-extraction cheat sheet

Arg kindWhere it livesHow to read it
address indexedl.Topics[n]common.BytesToAddress(l.Topics[n].Bytes())
uint256 indexedl.Topics[n]new(big.Int).SetBytes(l.Topics[n].Bytes())
uint256 (non-indexed)l.Data[off:off+32]new(big.Int).SetBytes(l.Data[off:off+32])
address (non-indexed)l.Data[off+12:off+32]last 20 bytes of the 32-byte word

Indexed args fill Topics[1..3] in order; non-indexed args are ABI-packed left-to-right in Data, 32 bytes each.

Gotchas

⚠️ Shared topic0. ERC-20 vs ERC-721 Transfer hash identically. ERC-721 = 4 topics + empty data; ERC-20 = 3 topics + 32-byte data. Guard with len(l.Topics) < 3 || len(l.Data) < 32.
⚠️ Same event name, different protocols. Aave/Compound/Comet/Morpho all have Supply/Borrow/Withdraw with different signatures → different topic0s → separate TopicXxx constants. Don't reuse.
⚠️ Canonical signature only. Tuples expand: CreateMarket(bytes32,(address,address,address,address,uint256)). Get one char wrong and topic0 won't match real logs — verify against an on-chain log.
⚠️ Return errSkipEvent, not a hard error, for "not for us" logs — DecodeBlock treats real errors as decode failures (debug-logged), skips are silent.

Process (non-negotiable, from CLAUDE.md)

FORTA-XXXX Linear ticket exists before the first edit · PR references it · make test && make lint + gofumpt -w before every commit · never push to main.

Source of truth: pkg/decoder/decoder.go, pkg/decoder/topics.go, pkg/types/event_payloads.go. Copy from the 20+ live r.Register(...) calls in NewRegistry. ⌘P → Save as PDF for a printable card.