How a decoder reads a raw log — on your home turf, the Gnosis Safe family. ~13 min.
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.
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.
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.
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
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.
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]) }
[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.
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
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.
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 shape | Where the param lives | Example |
|---|---|---|
| A · indexed | topics[1..3], one 32-byte word each | AddedOwner → owner |
| B · fixed data | data at a fixed word offset | ProxyCreation → proxy, singleton |
| C · dynamic data | data: a head pointer → tail [len][entries] | SafeSetup → owners[] |
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[].
topics[0] of every log holds what, and how does L3 use it?AddedOwner's owner is read from topics[1] via BytesToAddress(l.Topics[1].Bytes()). Why slice the word?ProxyCreation has no indexed params — both addresses are in data at offsets [:32] and [32:64]. Why those positions?SafeSetup's data, the owners array's head slot holds a value. What is it?owners[], what's the sequence?n > (len(data)-ao-32)/32 rejected before any multiply?len(Topics) == 1 when it needs topics[1]. What happens?*DecodedEvent with a Type and typed Payload. What does that uniformity buy?topics (signature + ≤3 indexed words) and data (everything else, ABI-encoded).DecodedEvent output and how it lets the write path route by Type.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.