Decoding Your Own calldata
- 9 minsLearn To Read calldata
How many people have to look at a website bottom that says Mint or Claim and just blindly call a transfer function instead? At least check the calldata Anon.
This is by no means a fool proof method, nothing beats taking a look at the code itself or simulating the TX pre execution, but its a small step in the right direction.
This is not a guide to how calldata works so for a comprehensive understanding of how calldata works, I recommend checking out these two awesome articles:
However, here’s a brief overview to freshen up your knowledge:
calldata is a specific data location in the Ethereum Virtual Machine (EVM), referring to raw hexadecimal bytes transmitted during any message call between two addresses. These bytes are organized sequentially, mirroring the structure of memory.
The key distinction between calldata and memory lies in the fact that calldata is read-only. An important detail is that the initial 4 bytes of calldata represent the function selector, while the subsequent bytes contain the function parameters. Each argument consumes 32 bytes, or a full word. If the argument’s type is smaller than 32 bytes, padding occurs either on the right or left, depending on the type.
A helpful way to conceptualize the disparity between calldata and memory is to recognize that calldata is allocated by the caller, whereas memory is allocated by the callee. As mentioned above each argument consumes a full 32 bytes or a full word.
When dealing with static types the encoding is handled as follows:
- bool: 0000..0000 is false, 0000..0001 is true
- uint< M >: Hex-encoded big-endian encoding of the integer, padded on the higher-order (left) side with zeros such that the length is 32 bytes.
- address: Encoded as uint160
- Bytes< M >: Hex-encoded bytes padded with trailing zero-bytes to a length of 32 bytes.
- int< M >: Hex-encoded big-endian encoding of the integer, with 0xff padding for negative integers and 0x00 padding for non-negative integers.
- Enum: Encoded as uint8
Let’s take a look at an example of the approve()function for an ERC20 token contract, which takes 2 arguments. The first argument is an address, encoded as a uint160, and the second argument is a uint256 which will be Hex-encoded and padded on the higher-order (left) side with zeros.
The address we want to approve as a spender will be the Uniswap Permit2 contract. The address is 0x000000000022D473030F116dDEE9F6B43aC78BA3 we want to approve 1 USDC. If you are using Metamask, click view details and you should see the calldata.
But is that what really happens? Let’s break it down. The first 4 bytes are allocated to our function selector, which will be 0x095ea7b3. To see what we are calling via the function selector we can check it on openchain by Samczsun or use Foundry’s cast.
What’s the rest of it?
The next argument is the address that we know would be the spender of our tokens 000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3 You might be thinking this does not look like an address but this is how it looks when put in a full word, if you remember from above the address is padded on the left side so our 20 bytes address 000000000022d473030f116ddee9f6b43ac78ba3 is padded to become a full word.
The next argument is a uint256 that we know is going to be the amount of tokens we are approving for the spender to spend.
We were only supposed to approve 1 USDC so what is the large value?
00000000000000000000000000000000000000000000000000000000000f4240 As we know from the table above, a unit is hex-encoded. So if we decode f4240, we will see that it is the hexadecimal representation of 1000000 or 1e6, which corresponds to 1 USDC. In this case, USDC has 6 decimals.
But what about dynamic types?
Dynamic types are a bit more tricky.
Encoding dynamic types introduces complexity as their encoding relies on the data’s length. The encoding process for dynamic types unfolds as follows: The initial word signifies the byte offset of the data from the context’s beginning, termed as “offset.”
In most scenarios, the context marks the commencement of the calldata argument block, specifically the initial word of the calldata following the function selector. However, if the dynamic type is nested within another, the context shifts to the commencement of the outer dynamic type’s data block.
The offset is encoded as a uint256 and is left-padded with zeroes if required. The word at context[ offset ] indicates the data’s length in bytes, referred to as “length.” Length is encoded as a uint256 and left-padded with zeroes if needed. For bytes and strings, the length denotes the number of bytes the encoded data occupies. The encodings undergo right-padding with zeroes if necessary, potentially spanning multiple words if the length exceeds 32 bytes.
For dynamic-length arrays (e.g. T[]), the length represents the array’s element count. The encoding involves concatenating the encodings of each element in sequential order.
For an excellent example, I recommend checking out the Solidity docs Let’s walk through another and a bit longer example.
I know that’s a lot, but let’s work through it one step at a time. The function we are calling looks like this, and if you’re curious where this function is from, it is from GMX.
But let’s see if our calldata matches up with what we are expecting to see First, we start with the function selector 0x5b88e8c6. We will check it using Foundry. We can see that what we should be expecting for this function is:
Here is a little cheat sheet to make it a bit easier to make sense of. I recommend referring back to these if you wonder why a type is when we dive into the calldata itself.
- array of type address = _path (swap path)
- address = _indexToken (token being traded)
- uint256 = _minOut
- uint256 = _sizeDelta (position size with leverage)
- bool = _isLong
- uint256 = _acceptablePrice
- uint256 = _executionFee
- bytes32 = _referralCode
- address = _callbackTarget
Now let’s see if that is what our calldata shows us But wait 0000000000000000000000000000000000000000000000000000000000000120 doesn’t look like an array of addresses. If you remember from above, the first word is going to store the offset to where our array information will be stored. We will see where it is soon enough.
000000000000000000000000b31f66aa3c1e785363f0875a1b74e27b85fd66c7 This will be the address parameter, not to be confused with the array (this is not part of the array). This is the wrapped AVAX token contract address.
0000000000000000000000000000000000000000000000000000000000000000 Then we have a fully empty word. Why is that? This simply means that the third parameter, uint256, is passed in as 0 and occupies a whole word for that.
00000000000000000000000000000000000231df300cdaaccc2e0e3d8465add2 As we know, units are in hex. So if we turn it into decimals, we get 11396114538709512761015567900847570, which is a really big number, isn’t it? It might seem that way when looking at it, but if you understand GMX’s codebase, you will understand that they operate with 30 decimal places for the most part.
0000000000000000000000000000000000000000000000000000000000000001 We remember from our static type table that a bool can either be 32 bytes of 0s or 31 bytes of 0s and 1 byte of 01 and this is the latter so we know that it was true.
00000000000000000000000000000000000000d8e8c9b4c34c47649895d50000 Another really large unit256 this one represents the price we are willing to accept 17185327473077970000000000000000.
00000000000000000000000000000000000000000000000000470de4df820000 Our next slot stores uint256 and if we turn it back from hex we get 20000000000000000, The fee we are paying to GMX to execute our order.
then we have two empty words next to each other 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 This will be the next two arguments: bytes32 and the last address. Why are they empty? If you see the function parameters above, it would indicate that the user did not provide a referral code or a callback contract (most likely because the trader was using an EOA).
We have no more function parameters to go through, yet we have two more words in the calldata. What are these used for? I’m sure you already knew. This is our information from the dynamic array we passed in as the first parameter. 0000000000000000000000000000000000000000000000000000000000000001 represents the length of our array which in this case is only 1, and 000000000000000000000000b31f66aa3c1e785363f0875a1b74e27b85fd66c7 is the Only the value stored in the array, and as we know, this is the wrapped AVAX token contract address.
That concludes our small course on how to read calldata. If you want to try it out for yourself as an exercise, see if you can write out your own calldata for a simple one or two-parameter function like balanceOf or approve, and then check if you got it correct.
You might as well check the calldata Anon, it doesn’t take that long to do. If you are looking for more reading I would recommend checking out This blog post by jbecker and This youtube video by Owen Thurm
That was all for me. Thanks for sticking with me, and don’t hesitate to reach out with any questions or just to talk web3 sec. You can find me on Twitter @0xkato.