Lesson 44 · Hot-Path Mechanisms · Deeper Track

Bytes to a typed event

How a decoder reads a raw log — on your home turf, the Gnosis Safe family. ~13 min.

Builds on: L3 · L22 · L25 Anchor: indexed vs data, ABI encoding New: three decode shapes New: adversarial-input guards

L3 showed the dispatch: topic0 → registry → the right decoder. This lesson opens what a decoder actually does — turn a raw log's bytes into a typed event. It's pure EVM, your strongest ground, and the whole skill is "know which bytes hold which parameter." We'll learn it on the Gnosis Safe events you already know feed multisig expansion (L22) and admin discovery (L25) — from the trivial case to the gnarly one, plus the guard that keeps a malicious log from OOM-ing the indexer.

Your anchor: a log has two byte regions
Every EVM log carries topics and data. topics[0] is the event signature hash (keccak256("AddedOwner(address)")) — that's L3's dispatch key. topics[1..3] hold up to three indexed parameters, each a 32-byte word (so they're filterable). Everything else — including all dynamic types — is ABI-encoded in data. A decoder's only job: pull each parameter from the region it lives in.

1 · Shape A — indexed param from a topic (trivial)

AddedOwner(address owner) has its one param indexed, so it's in topics[1] (decoder_protocol.go):

func (d *safeAddedOwnerDecoder) Decode(l Log, tx *RawTransaction) (*DecodedEvent, error) {
    if len(l.Topics) < 2 { return nil, errInvalidLog }   // validate first
    return &DecodedEvent{ Type: EventSafeAddedOwner, …,
        Payload: &SafeAddedOwnerEvent{ Safe: l.Address, Owner: common.BytesToAddress(l.Topics[1].Bytes()) }}
}

An address is 20 bytes but a topic word is 32, so BytesToAddress takes the low 20 bytes. l.Address — the contract that emitted the log — is itself a value here: it's the Safe. The decoder validates len(Topics) before touching Topics[1] — bad logs get errInvalidLog, never a panic.

2 · Shape B — fixed param from data (a step up)

ProxyCreation(address proxy, address singleton) has no indexed params (Safe v1.3.0+), so both addresses sit in data at fixed 32-byte offsets:

Proxy:     common.BytesToAddress(l.Data[:32]),    // first word
Singleton: common.BytesToAddress(l.Data[32:64]),  // second word
Why this event matters (callback to L24)
ProxyCreation is the authoritative "X is a multisig" signal — the emitter is the SafeProxyFactory, and a new proxy address is born here. It replaced probe-based multisig classification: rather than RPC-sniffing getOwners(), the discovery flywheel (L24) just watches for this log. Static-data params at fixed offsets are the common case for events with only fixed-size types.

3 · Shape C — a dynamic array from data (the head/tail scheme)

SafeSetup(address indexed initiator, address[] owners, uint256 threshold, …) is the gnarly one — it carries the Safe's initial owner set, invisible anywhere else (the gap L22's expansion needs filled at creation). The owners are a dynamic array, and that's where ABI's head/tail encoding shows up:

// data layout: head = 4 fixed words, then the array tail.
// head[0] = byte OFFSET of the owners array (a pointer into the tail)
// head[1] = threshold   head[2] = initializer   head[3] = fallbackHandler
threshold := new(big.Int).SetBytes(l.Data[32:64])      // head[1], a fixed word, read inline
off := new(big.Int).SetBytes(l.Data[0:32]).Int64()     // head[0] → where the array lives
n   := new(big.Int).SetBytes(l.Data[off : off+32]).Int64()  // the array's first tail word = its length
owners := make([]Address, n)
for i := 0; i < n; i++ {
    base := off + 32 + i*32                            // length word, then n entries
    owners[i] = common.BytesToAddress(l.Data[base : base+32])
}
The ABI head/tail rule, in one breath
Fixed-size params are stored inline in the head, in order. A dynamic param (array, bytes, string) puts a pointer (a byte offset) in its head slot, and the actual data — [length word][entries…] — lives later, in the tail. So decoding a dynamic array is: read the offset from the head, seek there, read the length, then read that many entries. Once you've seen it on address[] owners, every dynamic ABI param decodes the same way.

4 · The guard — log data is adversarial

Here's the line that separates a toy decoder from a production one. The array length n comes from untrusted log bytes. A naive make([]Address, n) on a malicious length word is an attack:

maxOwners := (int64(len(l.Data)) - ao - 32) / 32   // how many addresses could POSSIBLY fit
if n < 0 || n > maxOwners { return nil, errInvalidLog }   // bound BEFORE you allocate
Bound before you multiply — an alloc bomb, defused
Without this, a length word around 258 makes ao + 32 + n*32 overflow int64 and wrap negative, sneaking past a naive upper-bound check and reaching make([]Address, n) — which tries to allocate billions of entries and OOMs the indexer. The fix bounds n against what the remaining bytes could actually hold, before any multiply can overflow. Decoders parse adversarial input — anyone can emit a log shaped like a SafeSetup. Every length read from data must be bounded before it sizes an allocation. This is the security mindset the ingest layer lives by.

5 · The uniform output — every decoder ends the same

However it reads the bytes, a decoder returns the same shape: a *DecodedEvent with a Type (the event enum), the TxHash / LogIndex / Contract provenance, and a typed Payload struct (SafeSetupEvent, etc.). That uniformity is what lets the indexer's write path (L4) switch on Type and route each event to its handler without caring how it was decoded. Decoders normalize chaos into one envelope.

Decode shapeWhere the param livesExample
A · indexedtopics[1..3], one 32-byte word eachAddedOwner → owner
B · fixed datadata at a fixed word offsetProxyCreation → proxy, singleton
C · dynamic datadata: a head pointer → tail [len][entries]SafeSetup → owners[]
The ingest side, closed
L3 dispatched; this is the decode. You can now read any of the ~20 decoders: find each param's home (topic vs data, fixed vs dynamic), pull the bytes, bound anything untrusted, and emit the uniform DecodedEvent the write path consumes. And you've seen exactly how the Safe events that drive L22's multisig expansion and L25's admin discovery come into being — from raw bytes to owners[].

Check yourself

1. topics[0] of every log holds what, and how does L3 use it?
2. AddedOwner's owner is read from topics[1] via BytesToAddress(l.Topics[1].Bytes()). Why slice the word?
3. ProxyCreation has no indexed params — both addresses are in data at offsets [:32] and [32:64]. Why those positions?
4. In SafeSetup's data, the owners array's head slot holds a value. What is it?
5. To decode the dynamic owners[], what's the sequence?
6. Why is the owner count bounded as n > (len(data)-ao-32)/32 rejected before any multiply?
7. A decoder gets a log with len(Topics) == 1 when it needs topics[1]. What happens?
8. Every decoder, regardless of decode shape, returns a *DecodedEvent with a Type and typed Payload. What does that uniformity buy?
↳ Ask your teacher
Try: "How does the registry map topic0 to the decoder (the L3 side)?" · "Show me a decoder that reads from the transaction (tx) not just the log." · "Why does the codebase hand-decode instead of using go-ethereum's abi.Unpack?" · "How does a SafeSetupEvent become OWNS edges in the handler (L4/L22)?" · "Which events carry data the indexer can't get any other way?"

What you can now do

The ingest path, end to end
L1 (three binaries) → L3 (dispatch) → L44 (decode) → L4 (write). You can now follow a raw RPC log from the wire to a typed event to a graph edge — and you've closed it on the events you understand best. Combined with everything else, the pipeline has no step left unopened.

Grounded in: pkg/decoder/decoder_protocol.go (safeAddedOwnerDecoder indexed-topic, safeProxyCreationDecoder fixed-data, safeSetupDecoder head/tail dynamic address[] owners + bound-before-multiply alloc-bomb guard, errInvalidLog length validation, uniform *DecodedEvent{Type,TxHash,LogIndex,Contract,Payload}); dispatch via topics[0]→registry (L3). Verify against source — the code is the truth.