Skip to main content
glnc

Field notes

Decoding MultiSend and Governor calldata, from the terminal

by Arya Rahimi · · 11 min read

A DAO governance hash sat in my pasteboard. The vote closed in twenty minutes. Etherscan gave me execute(...) and a wall of bytes. Tenderly wanted me to log in. I wanted the answer in a terminal, in plain English. This post is about the part of glnc that got me there: a decoder that walks Governor, Timelock, MultiSend, and leaf calls instead of giving up at the first bytes field.

Why Etherscan stops at the first bytes field

The transaction was a routine governance 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 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 sat three layers down: a USDC transfer, a setReserveFactor, a setBorrowKink. Three encodings, one transaction.

Etherscan decoded the outer call, told me the second-layer payload was a blob of bytes, and stopped. That isn't a bug. The ABI decoder is a typed-call decoder. Once you hit a bytes parameter, the type system has nothing more to say. To go further you step outside the ABI and start guessing.

A Solidity bytes parameter is, to the ABI decoder, a byte buffer of unknown shape. To do anything useful you 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 are back where you started, one layer deeper.

That recursion is fine until you meet a payload that isn't ABI at all. MultiSend is the one you'll hit first. Its 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.

Then there's 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 forever. Both are wrong.

The walker

The glnc tx decoder is a small recursive function. Depth cap, byte-budget cap, and a branch for MultiSend's hand-rolled format. The shape:

# pseudo-code: decodeNested(calldata, depth, budget)
function decodeNested(hex, depth, budget):
budget.consume(byteLen(hex))
if budget.exhausted: 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 byte budget matters more than the depth cap. A nested blob can stay under depth 3 and still expand past anything you want in memory. The budget threads through the whole tree as a shared accumulator, not per node.

Decoding Safe 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

Two variants ship in the wild. MultiSend allows both operations. MultiSendCallOnly is the variant Safe deploys by default and reverts when operation = 1. Same packed layout either way.

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, 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;
}

Twenty lines, not five, because of the guards. Every bounds check is there because a payload tried to claim a thirty-megabyte inner data field and asked the parser to allocate for it. Refusing on dataLen > MAX_BYTES and on offset + dataLen > buf.length is the difference between "decoder returns empty" and "node process eats your laptop."

CALL vs DELEGATECALL: the operation byte

The first byte of every MultiSend record is the operation type: 0 for CALL, 1 for DELEGATECALL. Flatten a MultiSend without surfacing this byte and 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. Voting yes on a proposal whose inner MultiSend hides a DELEGATECALL to an untrusted address hands 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.

Why depth = 3 is the cap

The depth-3 cap is pragmatic. Three layers covers every governance pipeline I've seen on mainnet: 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.

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.

A walked decode in practice

Here's the shape of a typical OZ Governor execution on Ethereum: Governor calls Timelock, Timelock batches three actions through a MultiSend, one of which is a USDC transfer to a contributor wallet. The output below is composed from the parts of real proposals I've fed the decoder; treat it as the shape, not a specific transaction.

glnc tx 0x9f4a8c2b...d33e ethereum
Hash 0x9f4a8c2b…d33e (composite, not a single real tx)
Chain ETHEREUM
Status success
From 0xbbf3…2929 (Proposer)
To 0xc0Da…64A2 (OZ Governor)
Gas 614,820 @ 18.2 gwei ($43.18)
Decoded call
execute(187) [OZ Governor]
└─ executeBatch(targets[3], values[3], payloads[3]) [OZ Timelock]
├─ multiSend(transactions) [Safe MultiSend]
│ ├─ CALL → 0xA0b8…eB48 (USDC)
│ │ transfer(recipient = 0x4d7e…0a11, amount = 250000.00 USDC)
│ ├─ CALL → 0xc00e…0a50 (governance-controlled market)
│ │ setReserveFactor(0.18e18)
│ └─ CALL → 0xc00e…0a50 (governance-controlled market)
│ setBorrowKink(0.85e18)
└─ unknown selector 0x4a7f1b03
Summary
Executed 3-call batch via MultiSend:
Transferred 250000.00 USDC to 0x4d7e…0a11;
Called setReserveFactor on the market;
Called setBorrowKink on the market

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 yellow line is the honest case: a selector that isn't in the registry. The decoder names it and moves on. 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 handles the cases in the registry and fails out loud on the rest. The gaps:

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. 4byte is the obvious fallback. The problem with 4byte is selector collisions: 0xa9059cbb resolves to both transfer(address,uint256) and many_msg_babbage(bytes1), and a blind lookup picks whichever the table happens to return first, not whichever is right. Plugging in 4byte without a confidence layer makes the decoder lie loudly instead of fail honestly.

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. My earlier notes touched on what made the Universal Router messier than expected.

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 concatenation of 65-byte (r, s, v) blobs sorted by recovered signer address ascending. Feeding it to decodeNested would either fail loudly or, worse, succeed and produce nonsense if the first four bytes happened 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.

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 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 what I want it to do, but it does mean the human summary is incomplete on the heaviest payloads. Raising the cap is one line; the reason it isn't 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 stops at the first bytes-typed argument, which is where the MultiSend payload lives. Walking it requires a custom byte parser, which 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 concatenation of 65-byte (r, s, v) blobs sorted by recovered signer address ascending.

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 for ERC-20, ERC-721, ERC-1155, and WETH deposit/withdraw events lives next door in events.js. Under a thousand lines of mostly-readable JavaScript.

The shape of governance calldata is specified by OpenZeppelin's Governor and Timelock docs and the corresponding Safe MultiSend docs.

If a payload breaks the decoder, open an issue with the hash and chain. Wrong decodes are more useful to me than right ones. They turn into the next registry entry or the next bounds check on the byte walker.