Solidity Function Dispatch
How a contract's bytecode uses the function selector to jump to the right function.
Function dispatch is the logic that allows a contract call to be routed to the correct function when EVM bytecode executes. A deployed contract is essentially a blob of runtime bytecode stored at an address. Whenever that contract is called, execution begins at the first instruction in its runtime bytecode and continues until the current call returns, reverts, runs out of gas, or otherwise halts.
It is worth noting that functions are not a concept built directly into the EVM. The EVM does not know what foo(), bar(), or a function selector is. Instead, the Solidity compiler generates normal EVM bytecode that reads the calldata, determines which function the caller intended to call, and jumps to the correct location in the bytecode.
This logic could have been implemented in many different ways. Dropping down to a lower-level language such as Huff gives you the freedom to implement function dispatch however you want.
Background Knowledge
There are a few pieces of background knowledge that are helpful for understanding function dispatch. Since this is a relatively low-level article, I am assuming the reader is reasonably technical, so these will only be surface-level refreshers.
4-Byte Function Selectors
A 4-byte function selector is the value typically used to identify an externally callable function within a contract. It is computed by taking the keccak256 hash of the function’s canonical signature and keeping the first 4 bytes.
The canonical function signature contains the function name followed by its argument types.
cast sig "foo(uint256)"
0x2fbebd38
cast sig "bar(uint256,address)"
0x60c7d734
It is important to note that selectors are not globally unique. There are only 2^32, or roughly 4.29 billion, possible 4-byte selectors, but there is an effectively unbounded number of possible function signatures. Because of this, selector collisions are inevitable.
Solidity prevents you from defining two externally callable functions in the same contract if they produce the same selector, but different contracts can still contain functions with colliding selectors.
Interacting With Contracts
Contract interactions happen through EVM message calls. The first call may come directly from a transaction, while additional calls can be made by other contracts using instructions such as CALL, STATICCALL, or DELEGATECALL.
These calls can contain calldata, which is simply a sequence of bytes provided to the contract. Under the standard Solidity ABI, calldata will typically contain the 4-byte function selector followed by the ABI-encoded function arguments.
cast calldata "foo(uint256)" 1
0x2fbebd380000000000000000000000000000000000000000000000000000000000000001
The first 4 bytes are the selector for foo(uint256), while the remaining bytes contain the ABI-encoded value 1.
A contract does not technically have to use this format. A contract written in Huff or raw EVM bytecode can interpret calldata however it wants. However, this is the standard convention used by Solidity contracts and most EVM tooling.
Function Dispatch
Below is a very simple Solidity contract with two public functions. Neither function does anything, but the contract is enough to demonstrate how linear function dispatch works.
contract FunctionDispatch {
function foo(uint256) public {}
function bar(uint256) public {}
}
A simplified Huff-style dispatcher could look something like this:
#define macro MAIN() = takes(0) returns(0) {
// Extract the 4-byte function selector.
push0
calldataload
0xe0
shr
// Check foo(uint256).
dup1
0x2fbebd38
eq
foo
jumpi
// Check bar(uint256).
0x0423a132
eq
bar
jumpi
// No matching selector.
push0
push0
revert
foo:
FOO()
bar:
BAR()
}
This is simplified Huff-style source code rather than the exact bytecode produced by Solidity, but it demonstrates the same general idea.
For this example, pretend we are sending a call with the following calldata:
0x0423a1320000000000000000000000000000000000000000000000000000000000000001
This calldata is attempting to call bar(uint256) with the value 1 as the argument.
Extracting the Function Selector
The first goal is to isolate the function selector and place it onto the stack. In this example, the selector is 0x0423a132.
This is done using the following instructions:
PUSH0
CALLDATALOAD
PUSH1 0xe0
SHR
For the stack examples below, the top of the stack is shown first.
PUSH0
PUSH0 places the value 0 onto the stack. This value will be used as the starting calldata offset for CALLDATALOAD.
[0x00]
CALLDATALOAD
CALLDATALOAD pops the value from the top of the stack and uses it as the starting offset into calldata. It then loads 32 bytes beginning at that offset.
Since the offset is 0, it loads calldata[0:32].
[0x0423a13200000000000000000000000000000000000000000000000000000000]
It is important to remember that 32 bytes is equal to 256 bits. If the calldata is shorter than 32 bytes, CALLDATALOAD does not automatically revert. Any missing bytes are treated as zeros.
PUSH1 0xe0
PUSH1 0xe0 places the value 0xe0 onto the stack. In decimal, 0xe0 is equal to 224.
[0xe0]
[0x0423a13200000000000000000000000000000000000000000000000000000000]
SHR
SHR performs a logical right shift. It pops the shift amount from the top of the stack and shifts the value directly beneath it.
In this case, the 256-bit calldata word is shifted right by 224 bits.
256 bits - 224 bits = 32 bits
Since 32 bits is equal to 4 bytes, only the function selector remains.
[0x0423a132]
We now have the function selector isolated on the stack.
Comparing the Function Selector
The next step is to compare the selector against each externally callable function in the contract.
The first comparison checks whether the selector matches foo(uint256).
dup1
0x2fbebd38
eq
foo
jumpi
DUP1 duplicates the value at the top of the stack.
[0x0423a132]
[0x0423a132]
We duplicate the selector because EQ will consume the two values it compares. We still need the original selector if the first comparison fails.
The selector for foo(uint256) is then pushed onto the stack.
[0x2fbebd38]
[0x0423a132]
[0x0423a132]
EQ compares the top two values on the stack. If they are equal, it pushes 1. If they are not equal, it pushes 0.
In this example:
0x0423a132 != 0x2fbebd38
so the result is:
[0]
[0x0423a132]
The location where the foo function begins is then pushed onto the stack. Huff labels make this easier to express, but at the EVM level this ultimately becomes the byte offset of a valid JUMPDEST.
[foo destination]
[0]
[0x0423a132]
JUMPI pops a destination and a condition. If the condition is nonzero, execution jumps to the destination. If the condition is zero, execution continues with the next instruction.
In this case, the condition is 0, so execution does not jump to foo. The original selector remains on the stack.
[0x0423a132]
Checking the Next Function
The dispatcher then checks whether the selector matches bar(uint256).
0x0423a132
eq
bar
jumpi
This time, the selectors are equal:
0x0423a132 == 0x0423a132
Because they match, EQ pushes 1. The destination of bar is then pushed onto the stack, and JUMPI transfers execution to the beginning of the bar function body.
There is no second DUP1 because this is the final selector comparison in the example. We do not need to preserve the original selector after this point.
If the selector does not match either function, the dispatcher reaches the default case and reverts. A real Solidity contract may instead route the call to a fallback or receive function, depending on how the contract is written.
Avoiding Fallthrough
One important detail is that function bodies must not accidentally fall through into one another.
For example:
foo:
FOO()
bar:
BAR()
This is only safe if FOO() always terminates execution or explicitly jumps somewhere else. Otherwise, execution could finish running the instructions for FOO() and then continue directly into BAR().
Each function body must eventually execute something such as RETURN, REVERT, or STOP, or explicitly jump to another controlled location.
The dispatcher also needs a default path. Without one, an unknown selector could accidentally fall through into the first function body.
Conclusion
As demonstrated, linear function dispatch is relatively simple. The dispatcher loads the first 32 bytes of calldata, shifts the word right by 224 bits, isolates the 4-byte function selector, compares it against the known selectors, and jumps to the matching function body.
This only requires a small set of EVM instructions and is fairly straightforward to understand. However, linear dispatch becomes less efficient as a contract gains more functions. If the target function is near the end of the dispatcher, the contract may need to perform many comparisons before finding a match.
Other dispatch strategies can reduce this overhead, including binary-search dispatch and constant-time dispatch. Those techniques will be reserved for later articles.