Skip to main content
glnc

Field notes

Decoding MultiSend and Governor calldata, from the terminal

by Arya Rahimi · · 11 min read

I had a Compound governance hash in my pasteboard and twenty minutes to decide whether to vote yes. Etherscan showed me executeTransactions(...) and a wall of bytes. Tenderly was closer but wanted me to log in. I wanted the answer in a terminal, in plain English, without an account and without opening a browser tab. This is a note about the part of glnc that finally got me there: the decoder that walks Governor, Timelock, MultiSend, and leaf calls without giving up at the first bytes field.

The shape of the problem

The transaction in question was a routine Compound parameter change. Its pipeline looked like every other DAO action that has shipped in the last three years. A proposer called execute on the Governor with a single proposal id. The Governor walked its target list, found a Timelock, and called executeBatch with an array of payloads. One of those payloads was a Gnosis MultiSend call wrapping several inner transactions packed into a single bytes field. The real action, a token transfer, a parameter setter, a reserve sweep, was three layers deep and encoded in three different ways.

Etherscan decoded the outer call, told me the second-layer payload was a blob of bytes, and stopped. That isn't a bug in Etherscan. It's the cost of being honest: the ABI decoder is a typed-call decoder, and once you hit a parameter of type bytes, the type system stops volunteering information. To recurse you have to step outside the ABI contract and start guessing.

Why most explorers stop at depth 1

What makes this hard is small but real. A Solidity bytes parameter is, to the ABI decoder, a byte buffer of unknown shape. There's no type metadata travelling with it. To do anything useful you need to take its first four bytes, look up that selector against a registry of known functions, and decode the remaining bytes against that function's argument signature. Once you do that, you've recursed: the new function may itself have bytes arguments, and you're back where you started, one layer deeper.

That recursion is fine until you meet a payload that isn't ABI at all. Gnosis MultiSend is the canonical example. The multiSend function takes a single bytes argument that, by convention, contains a concatenation of fixed-layout records. You can't feed it to decodeAbiParameters and get anything back. You have to hand-roll a byte walker, and the walker has to know exactly how MultiSend lays out its records or it will silently produce nonsense.

The third thing that makes this hard is fan-out. A single OZ Governor execute(address[],uint256[],bytes[],bytes32) can carry an arbitrary array of payloads, each of which can itself contain a MultiSend, each of whose inner calls can be another router. A naive decoder either bails at depth 1 or recurses without bound. Neither is what you want.

The walker

glnc's decoder is a small recursive function with three things going on: a depth cap, a byte-budget cap, and a switch between ABI decoding and the hand-rolled MultiSend parser. The shape is roughly this:

# pseudo-code: decodeNested(calldata, depth, byteBudget)
function decodeNested(hex, depth, budget):
if budget + byteLen(hex) > MAX_BYTES: return DROPPED
entry = lookupSelector(hex[0:10])
if not entry: return { selector: hex[0:10] }
params = abiDecode(entry.inputs, hex[10:])
children = []
if depth < MAX_NESTED_DEPTH:
if entry.selector == MULTISEND:
for op in walkPackedBytes(params.transactions):
children.push(decodeNested(op.data, depth+1, budget+))
else:
for input in entry.inputs:
if input.type in (bytes, bytes[]):
for child in toList(params[input.name]):
children.push(decodeNested(child, depth+1, budget+))
params.children = children
return { entry, params }

The constants live at the top of src/decoders/index.js: MAX_NESTED_DEPTH = 3 and MAX_NESTED_BYTES = 65_536. The second one matters more than people expect. Without a byte budget, a malicious payload can hand you a recursively-nested blob that fits within the depth limit but expands to more bytes than you can afford to keep in memory. The budget is accumulated across the whole tree, not per node.

The hard part: MultiSend's packed encoding

The Gnosis MultiSend payload isn't ABI. Its layout, per operation, is:

# MultiSend packed record
[operation: 1 byte ] // 0 = CALL, 1 = DELEGATECALL
[to: 20 bytes] // target address
[value: 32 bytes] // wei sent
[dataLen: 32 bytes] // length of inner calldata
[data: dataLen ] // inner calldata, variable length

Records are concatenated. No length prefix on the array. No padding. Walk the buffer with a moving offset, peel one record at a time, stop when the offset hits the end. The actual walker in src/decoders/multisend.js looks like this, slightly trimmed:

# src/decoders/multisend.js (trimmed)
export function decodeMultiSend(packedHex) {
const buf = toBytes(packedHex);
if (buf.length === 0 || buf.length > MAX_BYTES) return [];
const ops = [];
let offset = 0;
while (offset < buf.length) {
if (offset + 1 + 20 + 32 + 32 > buf.length) return [];
const operation = buf[offset]; offset += 1;
const to = bytesToHex(buf.slice(offset, offset + 20));
offset += 20;
let value = 0n;
for (let i = 0; i < 32; i++)
value = (value << 8n) | BigInt(buf[offset + i]);
offset += 32;
let dataLen = 0n;
for (let i = 0; i < 32; i++)
dataLen = (dataLen << 8n) | BigInt(buf[offset + i]);
offset += 32;
if (dataLen > BigInt(MAX_BYTES)) return [];
const n = Number(dataLen);
if (offset + n > buf.length) return [];
const data = bytesToHex(buf.slice(offset, offset + n));
offset += n;
ops.push({ operation, to, value, data });
}
return ops;
}

It's twenty lines and not five because of the guards. Every bounds check exists because some payload, somewhere, claimed it had a thirty-megabyte inner calldata field and asked the parser to allocate accordingly. Refusing on dataLen > MAX_BYTES and on offset + dataLen > buf.length is the difference between "decoder returns empty" and "node process eats your laptop."

The operation byte matters

The first byte of every MultiSend record is the operation type: 0 for CALL, 1 for DELEGATECALL. If you flatten a MultiSend without surfacing this, you lose the most important security signal in the whole payload. A DELEGATECALL runs the target's code in the caller's storage context. That's how Safe modules extend behaviour, and it's also how malicious payloads steal everything in the caller's slots. A vote on a proposal whose inner MultiSend hides a DELEGATECALL to an untrusted address is a vote to give that address full authority over the calling contract.

glnc keeps the operation byte alongside each decoded sub-call in the raw decoded branch of the JSON envelope. The human summary falls back to CALL when not flagged, which is the common case, and surfaces DELEGATECALL explicitly when present. The default is paranoid, not silent.

Why depth = 3 is the cap

The depth-3 cap is pragmatic, not theoretical. Three layers is enough for every governance pipeline I've seen in the wild: Governor at the top, Timelock in the middle, MultiSend at the bottom, leaf calls inside. Going deeper is rare in legitimate traffic and easy to weaponise in malicious traffic. An unbounded recursion is a footgun: a payload that nests a MultiSend inside a MultiSend inside a MultiSend can be arbitrarily wide and arbitrarily deep, and the cost of following it grows with each level.

At depth 3 the decoder still parses the MultiSend header so it can tell you how many sub-calls were skipped, but it marks each one as dropped rather than recursing. The four-byte selector and the count are usually enough to know whether you want to escalate to a manual deep-dive. If you do, there's a flag to lift the cap, but it isn't the default and it shouldn't be.

What this looks like in practice

A real-shape Compound governance execution on Ethereum: Governor calls Timelock, Timelock batches three actions through a MultiSend, one of which is a USDC transfer to a contributor wallet.

glnc tx 0x9f4a8c2b...d33e ethereum
Hash 0x9f4a8c2b…d33e
Chain ETHEREUM
Status success
From 0xbbf3…2929 (Proposer EOA)
To 0xc0Da…64A2 (Compound GovernorBravo)
Gas 614,820 @ 18.2 gwei ($43.18)
Decoded call
execute(proposalId = 187) [GovernorBravo]
└─ executeBatch(targets[3], values[3], payloads[3]) [OZ Timelock]
├─ multiSend(transactions) [Gnosis MultiSend]
│ ├─ CALL → 0xA0b8…eB48 (USDC)
│ │ transfer(recipient = 0x4d7e…0a11, amount = 250000.00 USDC)
│ ├─ CALL → 0xc00e…0a50 (Comet USDC market)
│ │ setReserveFactor(0.18e18)
│ └─ CALL → 0xc00e…0a50 (Comet USDC market)
│ setBorrowKink(0.85e18)
├─ +1 deeper call not expanded
└─ unknown selector 0x4a7f1b03
Summary
Executed 3-call batch:
MultiSend 3 inner calls: Transferred 250000.00 USDC to 0x4d7e…0a11;
Called setReserveFactor on Comet; Called setBorrowKink on Comet

That output is what falls out of buildSummary recursing through each child entry. The Governor branch recognises execute at the OZ Governor selector, notices it has a bytes[] argument named calldatas, and expands each one. The Timelock branch hits the same shape under a different selector. The MultiSend branch hands the packed bytes to the byte-walker, which spits out an array of (operation, to, value, data) records that get fed back into decodeNested at depth + 1. The standard ERC-20 transfer at the leaf is the easy part.

The two yellow lines are the honest cases. One sub-call hit the depth cap and got flagged rather than expanded; the other had a selector that isn't in the registry. Neither failure is silent. If you only care about the leaf calls, the same tree comes out under raw.decoded.children in the JSON envelope, where each child carries its registry entry, its decoded params, and its own children array. Pipe it to jq and walk it however you like.

glnc tx 0x9f4a8c2b...d33e ethereum --json | jq '.data.raw.decoded.children[0].params.children | length'
3

Things still missing

The decoder works well enough for the cases it knows about and fails in honest, predictable ways for the cases it doesn't. The gaps are worth naming.

The registry is hand-maintained. Every selector glnc decodes lives in src/decoders/registry.js as a literal entry with its 4-byte selector, function name, protocol label, and ABI input list. That covers ERC-20, WETH, Uniswap V2 and V3, the Universal Router, OZ Governor, GovernorBravo, OZ Timelock, Safe, and MultiSend. It does not cover everything else. When the decoder sees an unknown selector inside a MultiSend leaf, it emits unknown selector 0x… and moves on. The obvious next step is plugging in the 4byte directory as a fallback, and the obvious problem with that is that 4byte is full of selector collisions. A blind lookup will tell you confidently that an unknown function is watch_tg_invmru_… because that signature happens to share four bytes with whatever the contract author actually wrote.

Universal Router is its own packed format. Uniswap's router doesn't use MultiSend or a Safe execute pattern. Its execute(bytes commands, bytes[] inputs, uint256 deadline) packs a one-byte command per inner operation alongside a corresponding entry in the inputs array. Today glnc decodes the outer call and labels it as a Universal Router action; the per-command walker that would expand "V3_SWAP_EXACT_IN" into its real signature isn't in yet. The shape of the work is the same as MultiSend, but the table of commands lives in Uniswap's contracts, not in mine.

Cross-chain bridges. LayerZero, Across, Wormhole, and friends all have their own message envelopes with their own conventions for what counts as the inner call. glnc currently hits "unknown selector" on most of them. Each bridge is a small project to add and a slightly different model of what "the call" even means once the destination chain is different from the source.

Safe execTransaction signatures. The decoder recurses into the data field of Safe's execTransaction and deliberately skips the trailing signatures bytes field; see shouldRecurseBytesField in src/decoders/index.js. The signatures field isn't calldata. It's a packed array of owner signatures encoded per the Safe contract's own conventions, and feeding it back into decodeNested would either fail loudly or, worse, succeed and produce nonsense if the first four bytes happen to collide with a real selector. Recognising signature bytes properly means parsing the Safe owner set, ordering by signing convention, and matching each signature to the recovered address. Useful, but a separate feature rather than a free side effect of the nesting walker.

Quantitative limits on heavy proposals. The 65 KB byte budget is large enough for everything I've fed it from mainnet, but a single Tally-style omnibus proposal with ten Comet markets each having a five-parameter setter batch will eventually brush the ceiling. The decoder degrades to +1 deeper call not expanded rather than erroring out, which is the right failure mode, but it does mean the human summary is incomplete on the heaviest payloads. Raising the cap is one line; the reason it's not already raised is the same reason it exists at all.

FAQ

What is depth-3 calldata? A DAO governance execution usually nests three layers: Governor.execute carries the proposal calldata, that calldata is a Timelock.executeBatch call, and one of the timelock payloads is a Gnosis MultiSend wrapping the real leaf calls. Decoding "to depth 3" means walking all three layers and parsing the MultiSend packed bytes to surface each inner CALL or DELEGATECALL.

Does Etherscan decode MultiSend transactions? Etherscan decodes the outer call and then stops at the first bytes-typed argument, which is where the MultiSend payload lives. Walking it requires a custom byte parser, which is what glnc tx ships out of the box.

How do I decode a Safe execTransaction from the command line? Run glnc tx <hash>. The decoder recognises Safe execTransaction, recurses into its data field, and surfaces the inner call. The trailing signatures field is intentionally skipped because it isn't calldata; it's a packed array of owner signatures encoded per Safe's conventions.

Can glnc decode unverified contracts? Only when the four-byte selector matches an entry in the local registry. For everything else, glnc prints the raw selector, labels the call unknown selector, and moves on rather than guessing.

Does this work without API keys or a server? Yes. The CLI runs entirely on your machine. Transaction lookup hits a public RPC; calldata decoding is local. There is no telemetry binary and no server-side relay. The marketing site at glnc.dev does use Vercel Analytics for aggregate pageviews; the privacy page spells out what that means.

Where the source lives

Everything in this post lives under src/decoders/ in the glnc repository. The recursion is in index.js, the byte walker is in multisend.js, the selector table is in registry.js, and the receipt-log decoder that handles ERC-20, ERC-721, ERC-1155, and WETH deposit/withdraw events lives next door in events.js. Together they're under a thousand lines of mostly-readable JavaScript.

The shape of governance calldata is well-specified by OpenZeppelin's Governor and Timelock docs and the corresponding Safe MultiSend docs. If a payload breaks the decoder, the most useful contribution anyone can make is opening an issue with the transaction hash and chain. That's the input that turns into the next registry entry, the next protocol branch, or the next bounds check on the byte walker. Hashes that decode wrong are more useful than ones that decode right.