JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

From Solidity to bytecode: the ABI and the 4-byte selector

On-chain there is no Solidity — only bytecode. Follow a contract from source through the compiler to the bytes that actually run, and decode how a single function call becomes a 4-byte selector the contract dispatches on.

On-chain, your Solidity does not exist

Open any contract on a block explorer like Etherscan and you'll see a green checkmark next to "Contract Source Code Verified" — readable Solidity, neatly formatted. It is tempting to think the chain stores that source. It does not. What lives at the contract's address is a raw string of bytecode: a few thousand bytes like `0x6080604052348015...`. The Solidity you read on Etherscan was uploaded separately by the deployer, and Etherscan earned that green check by re-compiling it and confirming the result matches the bytes already on-chain. The source is documentation; the bytecode is the contract.

This guide closes the rung by crossing the last gap: from the high-level language you'll write to the low-level machine you already met. You traced opcodes on the stack, saw where contracts keep data, watched gas meter every step, and followed CALL between contracts. Now we answer two practical questions that everything above quietly assumed: how does Solidity become those bytes, and when someone calls `transfer(...)`, how does a pile of opcodes know which function to run?

The compilation pipeline: source to opcodes

The Solidity compiler, `solc`, takes source and walks it through several stages: it parses the text into an abstract syntax tree, type-checks it, lowers it (in the modern pipeline, into Yul, a readable intermediate assembly), optimizes, and finally emits opcodes which it packs one byte each into bytecode. The bottom of that pipeline is exactly the machine from the first guide in this rung — `PUSH1`, `ADD`, `JUMPI`, `SSTORE`. Solidity is sugar; the EVM only ever eats bytes.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    mapping(address => uint256) public balanceOf;

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    function transfer(address to, uint256 amount) external {
        require(balanceOf[msg.sender] >= amount, "insufficient");
        balanceOf[msg.sender] -= amount;
        balanceOf[to]        += amount;
    }
}
A tiny vault contract. We'll compile it and chase the transfer function all the way down to the bytes.
$ solc --bin --bin-runtime --abi --optimize Vault.sol

======= Vault.sol:Vault =======
Binary:                       <- CREATION (init) code: the tx 'data' that deploys
60806040523480156100...          runs the constructor, then RETURNs the runtime code

Binary of the runtime part:   <- RUNTIME code: what ends up STORED at the address
6080604052600436106100...        this is exactly what eth_getCode returns later

Contract JSON ABI:            <- the calling convention (used OFF-chain by callers)
[{"type":"function","name":"transfer",
  "inputs":[{"name":"to","type":"address"},
            {"name":"amount","type":"uint256"}],
  "outputs":[],"stateMutability":"nonpayable"}, ... ]
solc emits three things: creation bytecode, runtime bytecode, and the ABI JSON.

The ABI: a calling convention everyone agrees on

Here's the hard part. The runtime bytecode is just opcodes — it has no idea your function was named `transfer`, that `to` is an address, or that `amount` is a `uint256`. Names and types are erased at compile time; the chain stores no reflection data. So how does a wallet on the other side of the world build a message this contract will understand? Both sides agree, in advance, on a fixed encoding: the Application Binary Interface, or ABI. The ABI JSON you saw `solc` emit is the contract's published "here is how to talk to me." It lives off-chain — in your frontend, in Etherscan, in your tests.

The ABI encoding rules are rigid and worth knowing because they explain every calldata layout you'll ever debug. The unit is the 32-byte word. Static types (`uint256`, `address`, `bool`, fixed-size arrays) are each padded to a full 32 bytes — numbers and bools are right-aligned (left-padded with zeros), addresses too. Dynamic types (`bytes`, `string`, `T[]`) don't fit in place, so they use a head/tail scheme: the head holds a 32-byte offset pointing to where, later in the data (the tail), the value's length and contents are stored. This is why a `string` argument shows up as a number that looks like `0x20` followed, further down, by its length and the actual characters.

The 4-byte selector: a fingerprint of the signature

Encoding the arguments is half the job. The other half is telling the contract which function you want. The ABI's answer is the function selector: take the function's canonical signature, hash it with Keccak-256, and keep just the first 4 bytes. Those four bytes go at the very front of the calldata, before any arguments. Four bytes is tiny — it's a fingerprint of the signature, not the name itself — but it's enough for the contract to recognize the call.

canonical signature = "transfer(address,uint256)"
      rules: function name + '(' + arg types, comma-separated, ')'
             NO parameter names, NO spaces
             types are normalized: uint -> uint256, address payable -> address,
                                   a contract type -> address, an enum -> uint8,
                                   a struct -> a tuple (...)

keccak256("transfer(address,uint256)") = 0xa9059cbb...   (the full 32-byte digest)
                                          \________/
                                          keep the first 4 bytes

function selector = 0xa9059cbb
Deriving the canonical ERC-20 transfer selector. Other well-known selectors: balanceOf(address) = 0x70a08231, approve(address,uint256) = 0x095ea7b3.
call:  transfer(0x00000000000000000000000000000000DeaDBeef, 1000000)

  a9059cbb                                                          # selector (4 bytes)
  000000000000000000000000 00000000000000000000000000000000deadbeef  # arg 0: address, left-padded to 32 bytes
  00000000000000000000000000000000000000000000000000000000000f4240  # arg 1: uint256 = 1,000,000 = 0xf4240

  total calldata = 4 + 32 + 32 = 68 bytes
The full calldata for one transfer call: a 4-byte selector followed by each argument padded to a 32-byte word.

The dispatcher: how a contract routes a call

Now the runtime bytecode's job is clear. The very first thing it does on every call is read those 4 selector bytes and decide where to jump. The compiler builds a little block at the entry point — informally the dispatcher (or function selector router). It loads the first 32 bytes of calldata, shifts right by 224 bits to isolate the top 4 bytes, then compares that selector against each function's known selector in turn, using the `EQ` / `JUMPI` pattern you saw in the control-flow guide. The first match jumps into that function's code.

; ---- runtime bytecode entry: the function dispatcher ----
  PUSH1 0x04
  CALLDATASIZE        ; how many bytes of calldata were sent?
  LT                  ; calldatasize < 4 ?
  PUSH2 <fallback>
  JUMPI               ; if too short to hold a selector, go to fallback

  PUSH1 0x00
  CALLDATALOAD        ; load the first 32 bytes of calldata onto the stack
  PUSH1 0xE0          ; 224  (= 256 - 32)
  SHR                 ; >> 224  => isolates the top 4 bytes: the selector

  DUP1
  PUSH4 0xa9059cbb    ; transfer(address,uint256)
  EQ
  PUSH2 <transfer>
  JUMPI               ; selector matches -> jump into transfer's code

  DUP1
  PUSH4 0x70a08231    ; balanceOf(address)
  EQ
  PUSH2 <balanceOf>
  JUMPI

  ; ---- no selector matched ----
  JUMPDEST            ; <fallback>
  ; run fallback()/receive() if the contract defines one, else REVERT
The dispatcher in EVM assembly: isolate the selector, then a chain of EQ/JUMPI comparisons. Optimized compilers may use a binary search over selectors instead of a linear chain.

What if no selector matches? Control falls through to the fallback function. If the contract declared a `fallback()` (or a `receive()` for plain ETH transfers with empty calldata), it runs; otherwise the call simply reverts. This is why sending ETH to a contract with no payable fallback fails, and it's the seam that proxy contracts exploit: a proxy defines a fallback that catches every unmatched call and `DELEGATECALL`s it to an implementation — the trick you met in the previous guide.

Why it matters: verification, raw calls, and the metadata tail

Understanding this chain — source → bytecode → ABI → selector → dispatcher — is what lets you trust contracts you didn't write. When Etherscan "verifies" a contract, it recompiles the submitted source with the exact compiler version and settings and checks that the produced runtime bytecode matches what's deployed, byte for byte. A mismatch means the source is lying. You can also bypass tooling entirely and call a contract from the ABI by hand: a raw `eth_call` is nothing more than the contract address plus the calldata bytes we built above.

# call balanceOf(0xDeaDBeef...) with no tooling, just the raw calldata:
#   selector 70a08231 ++ the address, left-padded to 32 bytes
cast call 0xVaultAddress \
  0x70a0823100000000000000000000000000000000000000000000000deadbeef

# Foundry's cast can also compute selectors and encode for you:
cast sig    "transfer(address,uint256)"        # -> 0xa9059cbb
cast calldata "transfer(address,uint256)" 0xDeaDBeef 1000000
Calling a contract with raw calldata, and using Foundry's cast to derive selectors and encode arguments.

One last detail explains a recurring mystery: why do two contracts with identical logic sometimes have different deployed bytecode? Because `solc` appends a metadata tail to the end of the runtime bytecode — a CBOR-encoded blob holding the compiler version and the IPFS hash of the full metadata JSON, capped by a two-byte length marker. It begins with bytes spelling `ipfs` and is never executed (it sits past every `RETURN`). Change a comment, a filename, or the compiler patch version and that hash changes, so the bytes change, even though the behavior is the same. It's also why a `CREATE2` deployment — which derives an address from the exact init code — is sensitive to metadata: same logic, different metadata, different address.

That completes the tour of the EVM. You now have a mental model that runs end to end: Solidity is compiled by `solc` into bytecode that the EVM executes one opcode at a time; the ABI is the shared convention that turns a function call into calldata; a 4-byte Keccak-256 selector and a dispatcher route that call to the right code; and gas meters every step along the way. With this in hand, the next rung — writing real contracts in Solidity — is no longer a black box. You know what your code becomes.