$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

Ethereum Calldata Explained: Decoding a Real Multicall

Updated 11 min read

Quick answer. Ethereum calldata is a transaction's input: a four-byte function selector, then ABI-encoded arguments in 32-byte words. Static values sit in place; dynamic values get a head pointing to their tail, measured from the start of the enclosing encoding.

Reading Ethereum calldata, and knowing how its layout differs from memory, separates an advanced understanding of Solidity and the EVM from an intermediate one.

This guide decodes the calldata of a real transaction with very complex calldata, every piece of it. Then it compares how the same data is laid out in memory.

If you want the wider picture first, start with how the EVM works.

The Transaction

The transaction is on the Avalanche testnet and interacts with GMX V2. It creates an order; a later transaction executes it.

Scroll down on the explorer and you see a big blob of input data.

By the end, you will know what kind of order it creates, long or short, and on what.

The function selector

Start with the function selector: the first four bytes of the keccak-256 hash of a function's signature, its interface.

The selector is prepended to the calldata so the contract knows which function to call. Everything after those four bytes is the data to call it with.

Paste the four bytes into an Ethereum signature database and, since it is a common signature, you get multicall(bytes[]).

Foundry's cast can do the hashing and keep the first four bytes for you.

$ cast sig "multicall(bytes[])"
0xac9650d8

Why this calldata is complex

The call goes to GMX's exchange router, which is multicallable. That multicall is the top-level function in this transaction.

Its argument is a list of arbitrary bytes, and each item is itself the calldata for another function call. The multicall iterates through the list and delegatecalls the exchange router with each item.

That lets one transaction call several exchange router functions, such as sendWnt, sendTokens, createDeposit and createOrder.

So the blob is a list of bytes, and each element is more calldata. The raw top-level encoding comes last, once you have the tools to read it.

The First Call: sendWnt

Decoded, the argument is an array of only two items. Both look like function calls, because both start with a selector.

The signature database matches the first selector to sendWnt, send wrapped native token. On the exchange router, it takes a receiver address and an amount.

Copy the first item and run cast pretty-calldata to make it readable. It even looks up the common selector and matches it to sendWnt.

The first word is clearly an address.

Look it up and it is GMX's order vault contract, which makes sense: you send the wrapped native token to the order vault to fund the order.

This is the basic layout of calldata: 32-byte words, which you may know from memory. Static types are simply encoded one after another.

So the uint256 amount is the second word. Convert it with cast to-dec and it comes to about one wrapped native token, plus a small leftover amount that comes back later.

The Second Call: createOrder

The second item is much larger, and the signature database has no match for its selector.

Context helps here. This transaction creates an order, and after sending the token to the order vault, the next step is to call createOrder.

A struct with substructs and enums

createOrder takes a CreateOrderParams struct, which contains substructs and enums. Here it is with the enums shown as uint8.

struct CreateOrderParamsAddresses {
    address receiver;
    address callbackContract;
    address uiFeeReceiver;
    address market;
    address initialCollateralToken;
    address[] swapPath;
}
struct CreateOrderParamsNumbers {
    uint256 sizeDeltaUsd;
    uint256 initialCollateralDeltaAmount;
    uint256 triggerPrice;
    uint256 acceptablePrice;
    uint256 executionFee;
    uint256 callbackGasLimit;
    uint256 minOutputAmount;
}
struct CreateOrderParams {
    CreateOrderParamsAddresses addresses;
    CreateOrderParamsNumbers numbers;
    uint8 orderType;                // an enum
    uint8 decreasePositionSwapType; // an enum
    bool isLong;
    bool shouldUnwrapNativeToken;
    bytes32 referralCode;
}

The addresses substruct is the first piece of dynamic data. Its addresses alone would be static, but the swapPath array makes the whole substruct dynamic.

That in turn makes the entire CreateOrderParams struct dynamic. That changes how it is laid out in calldata.

Computing the selector

A selector like this is very rare, and a little harder to compute. Turn each struct into a tuple, and each enum into the uint8 it is under the hood.

$ cast sig "createOrder(((address,address,address,address,address,address[]),(uint256,uint256,uint256,uint256,uint256,uint256,uint256),uint8,uint8,bool,bool,bytes32))"
0x4a393a41

That is exactly the selector in the calldata. So everything after it encodes the top-level dynamic struct.

The puzzle

Copy that data and you see a selector followed by many words, starting with 0x20 and 0x1a0. What does it all mean?

You could guess and check.

Then comes an address, which looks like the start of the addresses struct, and a few words later a 0xc0 that would be the swap path's offset.

But an offset from where? Read with memory's rules, 0x20 would point to the next word, which also looks like an offset, and 0xc0 lands somewhere that makes no sense.

To resolve it, you need to know how dynamic data is laid out in calldata.

What Is Dynamic Data?

The Solidity ABI specification lists the dynamic types:

  • bytes
  • string
  • T[], a list of any type
  • T[k], a fixed-size list of any dynamic type
  • A tuple (T1, ..., Tk), if any of its elements is dynamic

A struct is a tuple of its fields. So a struct is dynamic only if one of its fields is dynamic.

Here, the address[] swap path is dynamic. That makes the addresses tuple dynamic, which makes CreateOrderParams, the tuple that contains it, dynamic too.

How Dynamic Data Is Encoded

Each element of a tuple is split into a head and a tail. Heads come earlier in the words, and a dynamic element's head points to its tail later on.

  • Static element: the head is simply its encoding, and there is no tail.
  • Dynamic element: the head is the offset of the beginning of the tail, and the tail holds the element's actual encoding.

In our view, the spec's formal notation here is hard to follow. The line that matters says the offset is relative to the start of the encoding of the enclosing tuple.

Strip out the values

To see the structure alone, encode an empty struct. With every value at zero, only the heads and their offsets remain.

function test_emptyParams() public pure {
    CreateOrderParams memory params;
    console.logBytes(abi.encode(params));
}

Run the test, prepend four placeholder bytes for a selector, and pass the output to cast pretty-calldata.

All the data is gone, but 0x20, 0x1a0 and 0xc0 are still there. These words are the structure of the calldata, the heads pointing to tails, not values.

Walk the heads

The whole CreateOrderParams is dynamic, so it has a head and a tail. The first word, 0x20, is that head, pointing to the tail where the struct's actual encoding starts.

Inside that encoding is another head, because the struct's first field, the addresses substruct, is dynamic.

Its value, 0x1a0, is measured from the start of the encoding being read, which is 0x20. That lands at 0x1c0, the start of the addresses.

From there come the receiver, callback contract, UI fee receiver, market and initial collateral token. The next word, at 0x260, is the swap path's head.

This is where the memory reading failed.

Measured from the start of the data, 0xc0 lands in the wrong place, and measured from its own word at 0x260, it would land beyond the calldata.

The rule says the start of the current encoding: the addresses struct, which began at 0x1c0. cast to-dec turns 0xc0 into 192, or six 32-byte words.

Six words from 0x1c0 is 0x280. That word is the swap path's length, zero, as expected for an empty swap path.

OffsetWordMeaning
0x0000x20Head of CreateOrderParams
0x0200x1a0Head of the addresses struct, measured from 0x020
0x040 to 0x100ValuesThe numbers struct, seven words in place
0x120 to 0x1a0ValuesOrder type, decrease swap type, isLong, unwrap flag, referral code
0x1c0 to 0x240ValuesThe five addresses
0x2600xc0Head of the swap path, measured from 0x1c0
0x2800x0Swap path length

Offsets in the table count from the first byte after the selector.

Decoding the Real Order

Now go back to the transaction and run cast pretty-calldata on the original data. The values are back, inside the same scaffolding of 0x20, 0x1a0 and 0xc0.

The addresses

Jump to 0x1c0 for the first address, the receiver. It matches the address that sent the transaction, the order's creator, who likely set themselves to receive any outputs.

The callback contract and UI fee receiver are both zero. That makes sense in GMX V2, where most transactions have neither.

The market address is a GMX market contract on the testnet explorer. The initial collateral token is wrapped AVAX on the testnet.

Then the swap path: its head is 0xc0, six words on is a zero length, and the array is empty.

The numbers

Back up at the numbers struct, there is no head and tail. Its fields are all uint256, so it is static and its values are filled in one by one.

  • Size delta USD: GMX uses 30 decimals of precision, so dividing by 1030 gives about $14.
  • Initial collateral delta amount: zero. That is fine for an increase order, which derives the initial collateral from how much was sent to the order vault.
  • Trigger price: zero. This looks like a market order, which does not need one.
  • Acceptable price: for a token with 18 decimals, GMX prices carry 12 decimals to reach 30. Dividing by 1012 gives about $14, wrapped AVAX's price at the time (mid-2023). The size is about one wrapped AVAX.
  • Execution fee: dividing by 1018 gives 0.0005. sendWnt sent 1.0005 wrapped AVAX: one as initial collateral and 0.0005 as the execution fee.
  • Callback gas limit: zero, since there is no callback contract.
  • Minimum output amount: zero, since no swap path is involved.

The enums and flags

Next comes the order type, 2. In GMX's order type enum, value 2 is a market increase order.

The decrease position swap type is zero, since this is not a decrease order. isLong is true, so this is a market increase of a long position.

shouldUnwrapNativeToken happens to be true. The referral code is empty.

To confirm, take the encoded bytes from the transaction and run them through abi.decode. The receiver, market, size delta, acceptable price, execution fee and the rest all match.

Calldata Versus Memory

Now compare the same struct in memory. In Foundry's Chisel, paste in the structs, converting the enums to uint8 so you need no enum definitions.

Put the bytes in memory, write a separator so you can see where they end, then decode them.

function decodeInMemory(bytes memory data)
    internal
    pure
    returns (CreateOrderParams memory params)
{
    assembly {
        let fmp := mload(0x40)       // free memory pointer
        mstore(fmp, not(0))          // separator: a word of all f's
        mstore(0x40, add(fmp, 0x20)) // bump the free memory pointer
    }
    params = abi.decode(data, (CreateOrderParams));
}

Update the free memory pointer at 0x40 yourself, because mstore does not. Otherwise, Solidity code that runs afterwards overwrites your separator.

Dump memory and find the separator. Above it, after the free memory pointer and similar words, sit the original bytes. Below it is the same struct as it lives in memory.

That layout may look more familiar. The first word, 0x440, points to the addresses struct, and the second, 0x520, points to the numbers struct. The static values follow in place.

The key difference: these pointers count from the beginning of memory, not from the start of some encoding. Go straight to 0x440 and the addresses begin there.

At 0x520 is the size delta USD, the start of the numbers struct. The swap path field holds 0x500, and the word at 0x500 is zero, the empty array's length.

The reason for the two layouts: calldata is a read-only store, while memory is read and write. Each encoding scheme is optimized for its own use.

For more on memory, see Yul and memory.

Decoding the Full Multicall

Now you can read the whole top-level calldata. Run cast pretty-calldata on all of it: a call to multicall, then a run of offsets.

A single bytes value is dynamic, and so is an array of any type. So the entire argument is dynamic, and its first word is the head pointing to everything else.

For a list, the first word of the tail is the length: 2, the two items. Next come the heads of the two items, each dynamic in its own right.

Those heads are offsets from where the current encoding starts, just after the length word. The first item's head puts you at 0x80.

The word at 0x80 is that item's length, 0x44 or 68 bytes: four bytes for the selector plus two words, the address and the amount, padded out to whole words.

The second item's head is 0xc0, six words. Six words on from the start of the encoding lands on the second item's length.

That length is 0x2a4: 0x2a0 of encoded struct plus four bytes for the createOrder selector. The rest is the struct you already worked through.

OffsetWordMeaning
0x0000x20Head of the bytes[] argument
0x0200x2Array length
0x0400x40Head of item 0, measured from 0x040, so 0x080
0x0600xc0Head of item 1, measured from 0x040, so 0x100
0x0800x44Item 0 length: the sendWnt call
0x0a0 to 0x0e0DataSelector, order vault address, amount
0x1000x2a4Item 1 length: the createOrder call
0x120 onwardDataSelector, then the CreateOrderParams encoding

That accounts for every byte. If all of this makes sense, you have reached the advanced side of Solidity development. If not, work through it again until each part clicks.

How Guardian Helps

Guardian's smart contract audits are carried out by senior researchers who read contracts down to the EVM, including the calldata, memory and assembly that encoding bugs hide in.

They attack protocol logic, integrations and privileged flows with exploit-driven review.

Frequently Asked Questions

What is a function selector in calldata?

It is the first four bytes of the keccak-256 hash of a function's signature. It tells the contract which function to run. To compute one for a struct argument, write the struct as a tuple and each enum as uint8.

Which types are dynamic in the ABI?

Dynamic types are bytes, string, any T[], a fixed-size array of a dynamic type, and any tuple with a dynamic element. One dynamic field, such as an address array, makes a whole struct dynamic.

Where do calldata offsets count from?

A dynamic element's head holds the offset of its tail, measured from the start of the enclosing tuple's encoding, not from the start of the calldata or from the head's own position.

How does calldata encoding differ from memory?

In memory, a struct holds absolute pointers to its nested structs, with static fields in place. Calldata uses offsets relative to each encoding. Calldata is read only and memory is read and write, so each layout suits its use.

Sources

Get low-level code reviewed

Guardian's researchers read calldata, memory and assembly directly, so encoding bugs do not reach production.