EVM notes: how smart contract function calls work under the hood, with IRL ERC20 example
Diving into function calls at bytecode level - with lots of illustrations
Going from writing Solidity to wanting to read bytecode? Here’s my summary of the Ethereum Virtual Machine anatomy and how code actually runs.
tldr; calldata => selector => JUMP
Introduction
As you’ll already know, Solidity gets compiled (by the Solidity compiler) into bytecode before it is deployed on the chosen (EVM) network.
Behind the scenes, this bytecode corresponds to a series of opcodes, which the Solidity language has kindly abstracted away for us so we don’t have to write all our smart contracts in assembly. (However, if you know yul, you’ll already be more familiar with how opcodes work.)
When you go to the block explorer and see the bytecode in its entirety, it can seem pretty impenetrable. But with the right knowledge and tools, this bytecode can be broken down into understandable parts. Let’s look at what those parts are.
Runtime vs creation code
The first thing to know is that runtime code is not the same as creation code.
What does runtime code mean?
Runtime code is compiled from your smart contract and represents that contract.
And creation code?
Creation code is a kind of wrapper that runs once at deployment. It includes logic to construct and return the runtime code, plus any constructor setup. The runtime code is the output of that execution.
ERC20 example
Now we’re going to look at the SAND ERC20 token on Etherscan.
Underneath the contract code you can see “Contract Creation Code” and “Deployed Bytecode”. What’s the difference here?
Here, the “Deployed Bytecode” corresponds to the contract’s runtime code.
Follow along
Grab the deployed bytecode of the SAND token and paste it into evm.codes/playground - or us cast disassemble <code> locally to obtain the opcode breakdown. See here for a complete list of Ethereum opcodes.
Let’s now take a look at what this runtime code does and how we can interpret the opcode breakdown.
Function selectors
Function selectors route the contract execution.
We can see from Etherscan that the SAND contract has a number of Read functions;
and a number of Write functions;
and that these must feature in the bytecode in some way. When someone (or another smart contract) calls one of SAND’s functions, what happens?
Calling a function
First, let’s look at what happens when a user calls the burn function. When a user calls the burn function, they must specify the amount of SAND to burn.
Let’s encode a user function call and see what that looks like.
Say I want to burn 10 of my SAND. And recall that we must specify the correct decimals for ERC20 tokens! SAND has 18 decimals, so we must write 10 SAND as 10^18 SAND.
cast calldata “burn(uint256)” 10000000000000000000
gives
0x42966c680000000000000000000000000000000000000000000000008ac7230489e80000
where 0x42966c68, the first 4 bytes, is the function signature, and 0x8AC7230489E80000 is the hexadecimal encoding of 10000000000000000000 in decimal i.e. the amount that we are passing to our function.
What happens when the contract receives this input?
At byte offset 0x0f, the CALLDATALOAD instruction loads the first 32 bytes of calldata (starting at offset 0) onto the stack.
The contract then compares the extracted 4-byte function selector against a series of hardcoded values in its dispatcher logic, jumping to the corresponding function’s bytecode if a match is found. We’ll take a look at how this works shortly. Essentially, if the function signature matches, a ‘jump’ takes place. Otherwise, the contract execution continues to step through the opcodes until a match is found (or not).
Typically, function signatures are compared with EQ opcode. Here, you’ll see that GT is also used. This is due to highly optimised / older Solidity compiler patterns and is used for binary search branching. GT essentially creates a split in the code: if selector > pivot, go to “upper half of functions”. If not, fall through to “lower half”. Then the lower half uses EQ.
Examining a function call
Let’s now make the mental connection between the runtime bytecode and the opcodes we’ve heard so much about.
Here’s the bytecode for the first part of the contract’s runtime code.
0x6080604052600436106101ac5760003560e01c80636e9960c3116100ec578063b01b0ef71161008a578063dc2173f311610064578063dc2173f31461082b578063dcdf5158146108e2578063dd62ed3e14610974578063e18aa335146109af576101ac565b8063b01b0ef71461070c578063bb1e23cb14610721578063cae9ca51146107a6576101ac565b80638f283970116100c65780638f2839701461066557806395d89b41146101b1578063a9059cbb14610698578063ac9fe421146106d1576101ac565b80636e9960c3146104dc57806370a082311461050d5780637dd711c414610540576101ac565b80632b9917461161015957806342966c681161013357806342966c681461041157 The below illustrations show what we get when we disassemble the extract we’re stepping through, and I’ve added the function names so you can see where they occur. I’ve split the bytecode from the extract above into separate lines, so that we can see which opcodes appear. You’ll see that after a while, some of the opcodes reappear in a kind of pattern. This is good to know, as it will help us understand more bytecode extracts in future, as well as begin to identify how vulnerabilities can show up at the bytecode level.
Remember, this is just the first part of the SAND contract.
Check length of calldata; revert if too small to contain function sig
6080 PUSH1 0x80, push 1-byte value onto stack
6040 PUSH1 0x60, push 1-byte value onto stack
52 MSTORE, write a word (32bytes) to memory
6004 PUSH1, 0x04 push 1-byte value onto stack
36 CALLDATASIZE, length of msg data in bytes
10 LT, compares top two items a and b on the stack, checks a<b
6101ac PUSH2 0x01ac, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
Load calldata
6000 PUSH1 0x00, push 1-byte value onto stack
35 CALLDATALOAD, read word from msg data at index
60e0 PUSH1 0xe0, push 1-byte value onto stack
1c SHR, logical shift right
Compare function signature in call with contract function signatures
80 DUP1, clone top value a on stack
636e9960c3 PUSH4 0x6e9960c3, push 4-byte value onto stack getAdmin()
11 GT, compares top two items a and b on the stack, checks a>b
6100ec PUSH2 0x00ec, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63b01b0ef7 PUSH4 0xb01b0ef7, push 4-byte value onto stack getExecutionAdmin()
11 GT, compares top two items a and b on the stack, checks a>b
61008a PUSH2 0x008a, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63dc2173f3 PUSH4 0xdc2173f3, push 4-byte value onto stack approveAndExecuteWithSpecificGasAndChargeForIt(address,address,uint256,uint256,uint256,uint256,address,bytes)
11 GT, compares top two items a and b on the stack, checks a>b
610064 PUSH2 0x0064, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63dc2173f3 PUSH4 0xdc2173f3, push 4-byte value onto stack
14 EQ, compares top two items a and b on the stack, checks a==b
61082b PUSH2 0x082b, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63dcdf5158 PUSH4 0xdcdf5158, push 4-byte value onto stack executeWithSpecificGas(address,uint256,bytes)
14 EQ, compares top two items a and b on the stack, checks a==b
6108e2 PUSH2 0x08e2, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63dd62ed3e PUSH4 0xdd62ed3e, push 4-byte value onto stack allowance(address,address)
14 EQ, compares top two items a and b on the stack, checks a==b
610974 PUSH2 0x0974, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63e18aa335 PUSH4 0xe18aa335, push 4-byte value onto stack transferAndChargeForGas(address,address,uint256,uint256,uint256,uint256,address)
14 EQ, compares top two items a and b on the stack, checks a==b
6109af PUSH2 0x09af, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
6101ac PUSH2 0x01ac, push 2-byte value onto stack
56 JUMP, jump if destination is valid
5b JUMPDEST, mark valid jump destination
80 DUP1, clone top value a on stack
63b01b0ef7 PUSH4 0xb01b0ef7, push 4-byte value onto stack getExecutionAdmin()
14 EQ, compares top two items a and b on the stack, checks a==b
61070c PUSH2 0x070c, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63bb1e23cb PUSH4 0xbb1e23cb, push 4-byte value onto stack paidCall(address,uint256,bytes)
14 EQ, compares top two items a and b on the stack, checks a==b
610721 PUSH2 0x0721, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63cae9ca51 PUSH4 0xcae9ca51, push 4-byte value onto stack approveAndCall(address,uint256,bytes)
14 EQ, compares top two items a and b on the stack, checks a==b
6107a6 PUSH2 0x07a6, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
6101ac PUSH2 0x01ac, push 2-byte value onto stack
56 JUMP, jump if destination is valid
5b JUMPDEST, mark valid jump destination
80 DUP1, clone top value a on stack
638f283970 PUSH4 0x8f283970, push 4-byte value onto stack changeAdmin(address)
11 GT, compares top two items a and b on the stack, checks a>b
6100c6 PUSH2 0x00c6, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
638f283970 PUSH4 0x8f283970, push 4-byte value onto stack changeAdmin(address)
14 EQ, compares top two items a and b on the stack, checks a==b
610665 PUSH2 0x0665, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
6395d89b41 PUSH4 0x95d89b41, push 4-byte value onto stack symbol()
14 EQ, compares top two items a and b on the stack, checks a==b
6101b1 PUSH2 0x01b1, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63a9059cbb PUSH4 0xa9059cbb, push 4-byte value onto stack transfer(address,uint256)
14 EQ, compares top two items a and b on the stack, checks a==b
610698 PUSH2 0x0698, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
63ac9fe421 PUSH4 0xac9fe421, push 4-byte value onto stack setSuperOperator(address,bool)
14 EQ, compares top two items a and b on the stack, checks a==b
6106d1 PUSH2 0x06d1, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
6101ac PUSH2 0x01ac, push 2-byte value onto stack
56 JUMP, jump if destination is valid
5b JUMPDEST, mark valid jump destination
80 DUP1, clone top value a on stack
636e9960c3 PUSH4 0x6e9960c3, push 4-byte value onto stack getAdmin()
14 EQ, compares top two items a and b on the stack, checks a==b
6104dc PUSH2 0x04dc, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
6370a08231 PUSH4 0x70a08231, push 4-byte value onto stack balanceOf(address)
14 EQ, compares top two items a and b on the stack, checks a==b
61050d PUSH2 0x050d, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
637dd711c4 PUSH4 0x7dd711c4, push 4-byte value onto stack
14 EQ, compares top two items a and b on the stack, checks a==b
610540 PUSH2 0x0540, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
6101ac PUSH2 0x01ac, push 2-byte value onto stack
56 JUMP, jump if destination is valid
5b JUMPDEST, mark valid jump destination
80 DUP1, clone top value a on stack
632b991746 PUSH4 0x2b991746, push 4-byte value onto stack approveFor(address,address,uint256)
11 GT, compares top two items a and b on the stack, checks a>b
610159 PUSH2 0x0159, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
6342966c68 PUSH4 0x42966c68, push 4-byte value onto stack burn(uint256)
11 GT, compares top two items a and b on the stack, checks a>b
610133 PUSH2 0x0133, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
80 DUP1, clone top value a on stack
6342966c68 PUSH4 0x42966c68, push 4-byte value onto stack burn(uint256)
14 EQ, compares top two items a and b on the stack, checks a==b
610411 PUSH2 0x0411, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pc
Illustrated steps 1: Checking the calldata
6080 PUSH1 0x80, push 1-byte value onto stack6040 PUSH1 0x60, push 1-byte value onto stack52 MSTORE, write a word (32bytes) to memory6004 PUSH1, 0x04 push 1-byte value onto stack36 CALLDATASIZE, length of msg data in bytes 10 LT, compares top two items a and b on the stack, checks a<b0x24 is the hexadecimal representation of the length of our calldata in bytes (36). Remember, a is the ‘top’ item on the stack, b is the ‘second’ item. So LT checked the following:
24 < 4 ? 1 : 0
The check is false, so a and b are popped off the stack and 0 is added to the stack.
6101ac PUSH2 0x01ac, push 2-byte value onto stack1ac corresponds to contract position 428. We’ll see next why this is pushed to the top of the stack.
57 JUMPI, jump to destination if condition met; or increment pcWe know that JUMPI checks the following:
$pc := condition ? dst : $pc + 1
So what actually happened for the stack to be empty?
Remember, our playground shows we’re at contract position 0x0c (12):
A note about the Program Counter
The program counter is essentially the way that our list of opcodes is indexed, as you can see in the below image. It is always 0 at the beginning of an execution and increments by 1 for all opcodes except for PUSH opcodes, which are followed by 1 or more bytes. JUMPI, at index (i.e. contract location) [0c], pops two parameters from the stack as inputs, dst [1ac] and condition 0, and causes the program counter to update if the condition is met such that the execution will jump to the destination specified. In this case, the condition is not met (0 == false) so the execution continues with the next opcode in our list (PUSH1) at the subsequent index [0d].
What would this JUMPI have done, if the condition was true instead of false? This means that the LT check above would have needed to return true (1), where a<b i.e. calldata length is less than 4 bytes. Why have this check? Well, if the calldata length is less than 4 bytes, it means that no function signature has been specified in the calldata provided. Hence, execution will jump to the contract location for the start of the revert code ([1ac] or 428 in decimal). The destination must be marked as a JUMPDEST for execution to continue.
Illustrated steps 2: Loading the calldata
6000 PUSH1 0x00, push 1-byte value onto stack35 CALLDATALOAD, read word from msg data at index What happened to the 0 that was pushed to the stack prior to calldataload? calldataload takes this as its argument and reads a word (32 bytes) from the calldata at that index, msg.data[idx:idx+32], and then pushes the result to the top of the stack.
So in this case: msg.data[0:32]
60e0 PUSH1 0xe0, push 1-byte value onto stack1c SHR, logical shift rightSHR takes the top two items from the stack, offset and data, and shifts the data right by the offset given. The result looks familiar!
Illustrated steps 3: Execution continues
Now that we have the function signature provided in the calldata at the top of the stack, the contract is going to compare it to the function signatures embedded in its bytecode until a match is found (or not). After each comparison is made, if the condition is not met the contract execution continues as normal.
In the extract above, we can see many function signatures. I will not repeat these steps for every function - they are repetitive and it would make for a very long article!
80 DUP1, clone top value a on stack636e9960c3 PUSH4 0x6e9960c3, push 4-byte value onto stack getAdmin()11 GT, compares top two items a and b on the stack, checks a>b6100ec PUSH2 0x00ec, push 2-byte value onto stack57 JUMPI, jump to destination if condition met; or increment pcWe already know how JUMPI works. This time, the condition is met (1 == true) and execution jumps to the destination provided, [ec].
5b JUMPDEST, mark valid jump destinationExecution continues from location [ec], the destination.
The code block beginning at [ec] checks:
approveFor(address,address,uint256)burn(uint256)
and then branches accordingly. If you scroll up and take another look you will see this location towards the bottom of our list of opcodes.
Illustrated steps 4: Finding the burn code
80 DUP1, clone top value a on stack632b991746 PUSH4 0x2b991746, push 4-byte value onto stack approveFor(address,address,uint256)11 GT, compares top two items a and b on the stack, checks a>bThe comparison is false, so 0 is pushed to the top of the stack.
610159 PUSH2 0x0159, push 2-byte value onto stackA new destination is pushed to the top of the stack.
57 JUMPI, jump to destination if condition met; or increment pcHere, since the comparison was false, execution does not jump to the destination and contract execution continues.
80 DUP1, clone top value a on stack6342966c68 PUSH4 0x42966c68, push 4-byte value onto stack burn(uint256)By now you’ve probably guessed where this is going.
11 GT, compares top two items a and b on the stack, checks a>b610133 PUSH2 0x0133, push 2-byte value onto stack57 JUMPI, jump to destination if condition met; or increment pcThere is no jump since the condition was not met. Execution continues.
80 DUP1, clone top value a on stack6342966c68 PUSH4 0x42966c68, push 4-byte value onto stack burn(uint256)14 EQ, compares top two items a and b on the stack, checks a==bThis time, the check is not a>b, it’s a==b. Which is true.
610411 PUSH2 0x0411, push 2-byte value onto stack57 JUMPI, jump to destination if condition met; or increment pcIf you’ve followed the example this far, you’ll know that a jump is happening! The EQ condition was met, and contract execution now continues at the destination location, [411].
And we have reached the end of the extract above. We’ve arrived at burn!
If the contract is called with empty calldata
You’ve probably guessed already what happens if the contract is called with empty calldata. Yes, CALLDATASIZE is 0.
LT compares the two top stack values (calldata size and 4), returning 1 since 0 < 4. The next JUMPI uses this result to jump to 0x1ac.
6080 PUSH1 0x80, push 1-byte value onto stack
6040 PUSH1 0x60, push 1-byte value onto stack
52 MSTORE, write a word (32bytes) to memory
6004 PUSH1, 0x04 push 1-byte value onto stack
36 CALLDATASIZE, length of msg data in bytes
10 LT, compares top two items a and b on the stack, checks a<b
6101ac PUSH2 0x01ac, push 2-byte value onto stack
57 JUMPI, jump to destination if condition met; or increment pcAt location 1ac, there’s a JUMPDEST, which denotes this is a valid place to jump to (and the execution would fail if there was no JUMPDEST at this location).
The code continues to run and, ultimately, the call reverts, because no data was provided.
[1ac] JUMPDEST
[1ad] PUSH1 0x00
[1af] DUP1
[1b0] REVERTIf an incorrect (non-existent) function signature is provided in the calldata
If no matching function selector is found, execution typically reverts or invokes the fallback function. Here, the contract execution will not jump ahead to any function logic, because there is no appropriate logic to jump to, and will instead jump to the destination for revert, similar to the case we looked at above that has empty calldata.
To burn or not to burn
Recap:
we’re at location [411] now
we have the burn function selector on the stack
we have a value in memory
we finished stepping through the bytecode extract above and we’re ready to dive into the
burncode
You’ve been through a lot by now, let’s examine this in the next session.
I hope you enjoyed the article, thanks for reading!









































