Published on

ABI Encoding and EVM Calldata Demystified

Learn to read and understand EVM calldata

Authors

Whether youā€™re starting out developing your first smart contract, or youā€™ve already written and deployed a few of them, thereā€™s going to be a point where youā€™ll wonder how the interaction with your smart contracts is made possible. In this post weā€™re going to explore ABI encoding, the encoding format used to talk to an EVM (Ethereum Virtual Machine) to execute smart contract functions.

This will be a bit of a deep dive and fairly interactive, so buckle up.I hope youā€™ll enjoy it!

First and foremost, weā€™ll be using Foundryā€™s cast utility to play around with encoding and decoding ABI data. If you havenā€™t used Foundry before, I recommend my previous guide on Smart Contract Development with Foundry for a quick overview. With that out of the way, letā€™s start off by revisiting the Counter smart contract from the mentioned guide and explore what calling its functions using cast looked like.

Hereā€™s the contractā€™s source:

pragma solidity ^0.8.13;

contract Counter {
    uint256 public number;

    function setNumber(uint256 newNumber) public {
        number = newNumber;
    }

    function increment() public {
        number++;
    }
}

The first time we read its number property using cast call, the output looked like this:

$ cast call 0x5fbdb2315678afecb367f032d93f642f64180aa3 "number()"
0x0000000000000000000000000000000000000000000000000000000000000000

We also learned that the output weā€™re looking at is a bunch of hex bytes, and converting the value to a decimal base will yield the value weā€™d expect (which in this case was 0):

$ cast --to-base \
  0x0000000000000000000000000000000000000000000000000000000000000000 \
  10
0

Okay, why is this interesting? As mentioned in the beginning of the post, ABI encoding is used to interact with smart contracts. This means that both, input and output of calling a smart contract function, will be ABI encoded. The return value weā€™re looking at ā€“ that ā€œbunch of hex bytesā€ ā€“ is our ABI encoded output. So letā€™s explore this first.

ABI encoded bytes are split into 32 byte words, so whenever we want to interpret and understand input or output data that goes into or comes out of a smart contract, itā€™s helpful to slice that data into 32 byte pieces. In our case, the return value of the function call is exactly 32 bytes, so thereā€™s nothing to split anymore.

0x # 0x prefix to indicate hex data
0000000000000000000000000000000000000000000000000000000000000000 # bytes

The only thing we need, to decode this data and turn it into a human readable value, is knowing what data type weā€™re dealing with. We know the smart contractā€™s number property is of type uint256, so number() returns a uint256 as well. Thatā€™s why we can pass it to cast ā€”to-base and turn it into a decimal as done above.

Another way to do this more generally, is to use cast ā€”abi-decode which takes a function selector including its return type and a return value to decode. Based on the function signature, it can then determine what the encoded data represents.

Letā€™s run the following command to decode a given uint256 value and just to make things a bit more interesting, letā€™s assume the value to decode is something other than 0:

$ cast --abi-decode "number()(uint256)" \
  0x00000000000000000000000000000000000000000000008333401c60a03c1e38

2420216456551725801016

Nice, now we know how to decode a static value! If you want, you can try this with a few other values and data types. Okay, thatā€™s it for the warm-up.

Letā€™s look into calldata next.

Understanding calldata

Whenever we call a smart contractā€™s function, what weā€™re actually doing is generating a bunch of ABI encoded data and send it to the EVM. That generated data is called ā€œcalldataā€ and it includes all the information needed so that the EVM knows what function to call and with what parameter values.

To explore this further, letā€™s look at what happens when we call the smart contractā€™s setNumber(uint256) function. Below is the command that calls that function to set the contractā€™s number to 10:

$ cast send 0x5fbdb2315678afecb367f032d93f642f64180aa3 \
"setNumber(uint256)" 10 \
--from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

This gives us a transaction receipt that looks like this:

blockHash               0x0af7ff60c27cd0c474744a1c3318ab1522c89661c81ee313cb15eef798aebbbf
blockNumber             2
contractAddress
cumulativeGasUsed       43494
effectiveGasPrice       3875889325
gasUsed                 43494
logs                    []
logsBloom               0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root
status                  1
transactionHash         0xd0f134143acbd84f70af9d64382e609364167ee9a5c2f6ad82bed62c70799de3
transactionIndex        0
type                    2

Thereā€™s a bunch of information in this receipt, but the one thing weā€™re interested in now is the transactionHash. We can use it to inspect the transaction weā€™ve sent using cast tx:

$ cast tx 0xd0f134143acbd84f70af9d64382e609364167ee9a5c2f6ad82bed62c70799de3

blockHash            0x0af7ff60c27cd0c474744a1c3318ab1522c89661c81ee313cb15eef798aebbbf
blockNumber          2
from                 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
gas                  44948
gasPrice             3875889325
hash                 0xd0f134143acbd84f70af9d64382e609364167ee9a5c2f6ad82bed62c70799de3
input                0x3fb5c1cb000000000000000000000000000000000000000000000000000000000000000a
nonce                1
r                    0x5cb30735aa0281b270d6c79ef136bc0cdc27a52b0d6f6adec11f506b317b15b5
s                    0x4fb3793e81c1a82984a1c28fec36768a8e646fa3d6a980a4e739ecc917356b19
to                   0x5FbDB2315678afecb367f032d93F642f64180aa3
transactionIndex     0
v                    0
value                0

A transaction that calls a smart contract function will have an input value. That value is our calldata and hereā€™s what it looks like:

0x3fb5c1cb000000000000000000000000000000000000000000000000000000000000000a

Okay, so based on this, how does the EVM know which function we wanted to call, and with what arguments? It turns out that calldata is always constructed from a function selector, followed by parameter values that the function should be called with. The first 4 bytes, in our case 3fb5c1cb, is the function selector. The 32 bytes after that are the function arguments. So in this case, we can split up the bytes like this:

0x # hex prefix
3fb5c1cb # function selector
000000000000000000000000000000000000000000000000000000000000000a # param

How do we verify that this is true? Well, to get the function selector bytes, we create the keccak256 hash of the function selector and take the first 4 bytes.

In other words:

$ cast keccak "setNumber(uint256)"

yields a hash that looks like this:

0x3fb5c1cb9d57cc981b075ac270f9215e697bc33dacd5ce87319656ebf8fc7b92

And as we can see, the first 4 bytes match the 4 bytes of our calldata! The rest of the calldata can be decoded the same way we did previously using cast ā€”abi-decode.

Creating calldata without transactions

We donā€™t need to create a transaction to figure out what calldata weā€™ll send to the EVM. cast calldata will do it for us, given a function selector and its parameter values. The following command will produce the exact same calldata as our previous transaction:

$ cast calldata "setNumber(uint256)" 10

0x3fb5c1cb000000000000000000000000000000000000000000000000000000000000000a

This is cool because now we can play around with this and inspect encodings of different function signatures. Letā€™s see what happens when we have a function that expects multiple numbers:

$ cast calldata "setNumbers(uint256, uint256, uint256)" 1 16 3203

This returns the following calldata:

0xb3b3d608000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000c83

Which we can split up into smaller pieces and read as:

0x # hex prefix
0xb3b3d608 # function selector
0000000000000000000000000000000000000000000000000000000000000001 # param
0000000000000000000000000000000000000000000000000000000000000010 # param
0000000000000000000000000000000000000000000000000000000000000c83 # param

Awesome. With that knowledge at hand, letā€™s look at some more interesting data types.

Dynamic data types

So far weā€™ve only looked at encoding uint256 values which are considered static types. Static types are pretty much any value that canā€™t grow in size and fit into a single 32 byte word. Thatā€™s why the three parameters in the last example are simply stacked onto each other. Dynamic values on the other hand are values that are of non-fixed-size types. This includes bytes, strings and arrays. These types can represent values that might not fit into a single 32 byte word, so they have to be handled differently when encoded.

To explore this further, letā€™s assume we have a function that takes a string and generate some calldata using ā€œhelloā€:

$ cast calldata "sayHello(string)" "hello"

This produces the following calldata:

0xc3a9b1c50000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000

Which, as we now know, can be split up like this:

0x
c3a9b1c5
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000005
68656c6c6f000000000000000000000000000000000000000000000000000000

Okay this looks certainly a bit different from what weā€™ve seen before. Whatā€™s happening here?

When dealing with a dynamic type, the first byte word of the value always starts with an offset. The offset tells the EVM where the actual value in the byte stream starts. In this case, the offset is 20, which is the hex value for 32 in decimal, meaning that the value weā€™re interested in starts after 32 bytes at the second byte word. What follows is a byte word that tells us the length of the value in bytes, which in this case is 5. Finally, the next five bytes, 68656c6c6f, are the ā€œhelloā€ string!

Letā€™s do one more. This time weā€™ll inspect a dynamic array. Feel free to come up with a function selector yourself, otherwise, take the following command (sorry for the bad formatting, Substack doesnā€™t support scrollable code snippets):

$ cast calldata "setAddresses(address[])" \
"[
0x178412e79c25968a32e89b11f63B33F733770c2A,
0x95aB45875cFFdba1E5f451B950bC2E42c0053f39
]"

This creates the following calldata:

0x
b9571721
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000002
000000000000000000000000178412e79c25968a32e89b11f63b33f733770c2a
00000000000000000000000095ab45875cffdba1e5f451b950bc2e42c0053f39

At this point this is pretty straight forward. However, one thing to notice here, is that since weā€™re dealing with an array, the value in the byte word that describes the length is the number of elements in the array (instead of the length in bytes). In this case the length is 2 because thereā€™s two elements, and as we can see, every element is a single byte word again, representing an address (which by the way is also a static type)

Congrats! Now we can manually ABI decode static and dynamic values!

Where to go from hereā€¦

I tried to stick to simple examples in this post so readers have an easier time following, but I highly encourage everyone to play around with this in their own projects, especially where more complex transactions come into play.

I also recommend everyone who read this post to also read DeGatchiā€™s post on Reversing The EVM: Raw Calldata and Ijmaniniā€™s ABI Encoding Deep Dive, as Iā€™ve learned a lot from their content myself (thank you guys!).

In addition, make sure to check out the official docs for Foundry and cast specifically. There are a bunch of useful utilities in there I havenā€™t covered in this post. If you made it all the way here, Iā€™d appreciate some feedback (positive and negative) and please let me know what other topics I should explore and write about.

Iā€™m pretty active on Twitter, so feel free to ping me there!