- Published on
ABI Encoding and EVM Calldata Demystified
Learn to read and understand EVM calldata
- Authors
- Name
- š° r4bbit
- @0x_r4bbit
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!