BitrootBlog
Back to website ↗
© 2026 Bitroot · Content is for general information only and is not financial, investment, legal, or tax advice.
Editorial StandardsBack to website
← All articles
EVM foundations·2026/10/02·About 12 min

ABI in Detail: Selectors, Static Arguments, and Dynamic Types

Taking raw calldata apart to explain the ABI: the 4-byte selector comes from truncating the canonical signature, static types fill a whole word, and dynamic types are addressed two levels deep through an offset and a length; events write indexed arguments into topics in exchange for searchability. The ABI is not self-describing, so both decoding and upgrades are constrained.

An ERC-20 transfer's calldata usually begins with 0xa9059cbb, followed by two 32-byte words: the first is the recipient address, the second the amount. Nothing on-chain states that this byte string corresponds to transfer(address,uint256), and the EVM (Ethereum Virtual Machine) does not recognize function names either. It simply follows the dispatch logic compiled into the bytecode at deployment: compare the first 4 bytes of calldata and jump into the matching code section.

Where those 4 bytes come from, why some arguments sit right at the front while others have to be hunted down further back, and why a stretch of calldata cannot be decoded without an interface definition — these are the three things to grasp about contract interaction. This piece first lays out the ABI (Application Binary Interface) encoding rules, then works through a manual decoding pass using the sample calldata given in the specification document, and finally explains why event logs split arguments into the two locations, topics and data.

The selector is a hash truncation of the canonical signature, not the function name itself

The Solidity ABI specification defines the function selector in one short sentence: the first 4 bytes of calldata, taken from the highest-order 4 bytes of the Keccak-256 hash of the function signature. The signature here is the canonical form, which is not equivalent to how it is written in source: the function name followed by a parenthesized list of argument types, separated by a single comma, with no spaces, no argument names, and no data-location modifiers such as memory, calldata, or storage.

The canonical form also normalizes types. uint and int must be written uint256 and int256, address payable and contract types are both written address, an enum is written uint8, and a user-defined value type is written as its underlying type. A struct is written as its parenthesized component types, a tuple: if S has two uint256 fields, the signature of function f(S memory s) is f((uint256,uint256)), and the selector is taken from the first 4 bytes of keccak256("f((uint256,uint256))"). Return values take no part in the signature, consistent with Solidity's overload resolution: callers tell target functions apart by arguments alone, and a difference in return type cannot be used to disambiguate. Every character in the signature affects the result — add a space or write uint where uint256 belongs, and the selector comes out entirely different.

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

contract SelectorDemo {
    function selectorOfTransfer() external pure returns (bytes4) {
        // The string is the canonical signature: no spaces, no argument names, uint normalized to uint256
        return bytes4(keccak256("transfer(address,uint256)"));
    }
}

The direct consequence of a 32-bit space is that collisions are not a theoretical assumption. As of September 2026, the single selector 0xa9059cbb on 4byte.directory lists 6 candidate signatures: besides transfer(address,uint256), there are many_msg_babbage(bytes1), workMyDirefulOwner(uint256,uint256), and placeholder names such as func_2093253501(bytes); entries in that database can be submitted by anyone, so the count and the contents change over time. When two different functions share a selector, the calldata prefix alone cannot distinguish the caller's intent — only the target contract's code or interface definition can settle it.

The official Solidity documentation does not call this limitation out on its own, but at the implementation level solc does check for duplicate selectors within the same contract (including its inheritance chain): when two functions share a selector, compilation fails with Function signature hash collision (reproducible with a minimal contract), while no such check exists across contracts or across compiler versions. That also shows why a reverse lookup of function names through a 4byte-style database alone is unreliable: the database returns a candidate set, and under a collision there is no single answer to be had.

Static types fill exactly one word

Encoding starts at the 5th byte. The rules first split arguments into two classes, static and dynamic: static types are encoded in place, written straight into the argument byte stream; dynamic types put their actual content further back and leave only an offset in place.

The EVM's word size is 32 bytes, and static types always fill one word. uint<M> and int<M> are written big-endian with high-order padding: unsigned values are zero-padded, signed values are sign-extended with 0xff or 0x00. bool is equivalent to uint8, with true encoded as 1 and false as 0. address is equivalent to uint160, so a 20-byte address lands in the low-order bits of a 32-byte word, preceded by 12 zero bytes. The fixed-size byte types bytes<M> pad in the opposite direction: the value is left-aligned and zero-padded on the right out to 32 bytes. Both are static types, yet uint32(1) and bytes4(0x00000001) land in the same word at different positions — the former in the last 4 bytes, the latter in the first 4 — and this is the easiest thing to misread when working through a hexadecimal dump.

A fixed-length array <type>[M] is likewise classed as static when its element type is static; it is encoded by expanding it as a tuple, with the M elements laid out in sequence and no length written. Structs and tuples expand recursively the same way. The specification defines a static type as “all types other than bytes, string, T[], a T[k] whose element type is dynamic, and a tuple containing dynamic members,” and that definition is the only basis for deciding whether to read a value directly or follow a pointer.

Calldata itself carries a gas cost too, and it does not line up with the ABI's 32-byte alignment. EIP-2028 cut the cost of a non-zero byte in transaction data from 68 gas to 16 gas, while a zero byte still costs 4 gas; this cost is part of a transaction's intrinsic gas. Passing a very small value as uint256 still fills 32 bytes, the vast majority of them zero bytes, so the cost is low but not zero; conversely, packing several small integers tightly into bytes32 saves gas, at the price of readable argument boundaries on-chain — the decoding side has to know this custom layout.

Dynamic types are addressed two levels deep through an offset and a length prefix

bytes, string, and T[], along with fixed-length arrays and tuples whose elements are dynamic, all count as dynamic types. Their length depends on the runtime value and cannot be fixed at compile time, so they cannot occupy a fixed width in the argument byte stream.

The ABI uses a head/tail layout. The “heads” of all arguments come first, laid out in order: a static type's head is its own encoding, and a dynamic type's head is a 32-byte offset. The offset is measured from the start of the argument block — the first byte after the selector, not the start of the whole calldata — and this is the most common source of misalignment when decoding by hand. The length of the head depends only on the argument types, not on the argument values. After the heads come the “tails” of the dynamic arguments, in order; a tail's first item is the length, whose unit depends on the type: byte count for bytes and string, element count for arrays, and only then the content.

A string's length is the byte count after UTF-8 encoding, not the character count, so CJK text and emoji occupy several bytes each. The content is then zero-padded to a multiple of 32 bytes. Array elements apply the same rules recursively, and in a nested array such as uint256[][] each level has its own length and offset, with an offset always relative to the start of the encoding block at its own level. The specification states this design goal plainly: in the worst case, the number of reads needed to reach a value is no more than its nesting depth in the argument structure, and any element's data can be relocated wholesale without becoming invalid, because it depends only on relative addresses.

One easily overlooked detail is that offsets are not required to be minimal, and data regions are not required to be disjoint. The strict encoding mode defined in the specification requires a compact head with no gaps; the Solidity encoder always emits strict mode, but decoders do not enforce the check. In other words, the same semantically identical call may have several byte layouts, and a decoding implementation either accepts offsets with gaps or rejects them explicitly — the two choices diverge when clients are compared against each other.

Decoding a stretch of calldata by hand

The decoding example is taken directly from the Examples section of the Contract ABI Specification: a call to sam(bytes,bool,uint256[]) with arguments "dave", true, and [1,2,3], 292 bytes in total, wrapped at 32 bytes as follows.

0xa5643bf2
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000001
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000004
6461766500000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

The decoding steps can be reproduced strictly from the specification's definition:

  1. The first 4 bytes, 0xa5643bf2, are the selector, corresponding to the signature sam(bytes,bool,uint256[]). Note that uint in the signature must be written uint256.
  2. The argument block has 3 heads, each 32 bytes. The 1st word is 0x60, that is 96, the offset of the 1st argument, bytes; the 2nd word is 0x01, with bool as true; the 3rd word is 0xa0, that is 160, pointing at the 3rd argument, uint256[].
  3. The heads total 96 bytes, so the tail of the 1st argument starts at byte 96 of the argument block, and 0x60 is self-consistent. The tail's 1st word is 0x04, meaning the bytes value is 4 bytes long; the next 4 bytes are 64617665, which read as ASCII is dave; then the value is zero-padded out to 32 bytes.
  4. The tail of the 1st argument takes two words, 64 bytes in total, running from 96 to 160, so the offset of the 2nd dynamic argument is 0xa0. Its 1st word is 0x03, indicating the array has 3 elements; the next 3 words are 1, 2, and 3.

The same process can be written as a minimal decoder. The Python below handles only the one signature above; it serves to show that offsets are measured in bytes and where the length prefix sits.

# Input is the argument block after the selector, sliced into 32-byte words
def word(data, i):
    return int.from_bytes(data[i * 32:(i + 1) * 32], "big")

def decode_sam(args: bytes):
    bytes_off = word(args, 0)          # 1st word: byte offset of the bytes argument relative to the start of the argument block
    flag = word(args, 1) != 0          # 2nd word: bool
    arr_off = word(args, 2)            # 3rd word: offset of the uint256[]

    n = word(args, bytes_off // 32)    # Convert the offset to a word index, then read the length prefix
    s = args[bytes_off + 32: bytes_off + 32 + n].decode()

    k = word(args, arr_off // 32)      # The array likewise reads its element count first
    items = [word(args, arr_off // 32 + 1 + j) for j in range(k)]
    return s, flag, items              # ("dave", True, [1, 2, 3])

The ABI is not self-describing; a schema is a mandatory off-chain artifact

The specification says right at the start: this encoding is not self-describing, and decoding requires a schema. The engineering weight of that sentence is heavier than it reads.

What the EVM receives is only bytes; function names, argument names, and type information all disappear after compilation. Dispatch works by comparing the 4-byte selector, and the jump target is a compiler-generated switch; return data is likewise just a byte stream, and a caller that wants to read a dynamically sized return value needs operations such as RETURNDATASIZE and RETURNDATACOPY (introduced by EIP-211, activated in the Byzantium upgrade). There is nowhere on-chain to hold the ABI JSON; it can only be published alongside the source as a compiler artifact. Several concrete constraints follow.

For a block explorer to show “which function was called and with what arguments,” it must first have the verified source and ABI. An unverified contract can only display raw calldata, or fall back to a reverse lookup through a 4byte-style database, and that lookup yields a candidate signature set that cannot be pinned down under a collision. Indexers and data pipelines must know the ABI at deployment time, or they cannot index events and calls. Interface evolution has no compatibility mechanism such as field numbering either: adding, removing, or reordering a function's arguments changes the selector, a wholesale interface break for callers, so upgrades usually can only add functions rather than modify old ones. A proxy contract can add functions at the implementation layer, but selectors must stay stable; any “refactor” that changes a signature sends existing calls down the fallback branch, and the fallback only receives raw calldata and cannot recover the caller's intent.

Two more points are often misunderstood. Sharing a selector does not mean the encodings are interchangeable: transfer(address,uint256) and many_msg_babbage(bytes1) share 0xa9059cbb, yet their argument lengths and semantics are entirely different, so a tool facing a collision must judge by the contract code or the calldata length. A custom error's selector is likewise the first 4 bytes of the error signature, and any contract can return data matching some error signature; the specification therefore explicitly warns callers not to treat error data as information from a reliable source — it is fit only as a hint.

Events put searchability in topics and readability in data

Event encoding rules share their origin with function calls, but they land differently. A log consists of the contract address, at most 4 topics, and a stretch of data of arbitrary length. For a non-anonymous event, topics[0] is always the Keccak-256 hash of the event signature, with no 4-byte truncation — which is why filtering events with eth_getLogs uses the signature hash rather than the event name as its condition. Event signatures likewise use the canonical form, with uint normalized to uint256.

The remaining arguments split into two paths depending on indexed. Value-type arguments carrying indexed are encoded straight into topics[1] through topics[3]: integers are padded in the high-order bits, fixed-size bytes are padded on the right, and addresses take the low 20 bytes. A non-anonymous event allows at most 3 indexed arguments, and together with the signature topic that fills exactly 4; an event declared anonymous writes no signature topic and allows up to 4 indexed arguments, at the cost of losing the ability to filter by signature. Arguments without indexed are encoded into data under the ABI rules, and that data has a head/tail structure internally as well, with dynamic arguments still addressed by offset inside it.

The differences concentrate on indexed complex types. Once an array, string, bytes, or struct is marked indexed, what goes into the topic is the Keccak-256 hash of that encoding. A hash is not reversible, so such an argument can be searched precisely: precompute the hash of the target value and use it as a filter condition to hit the log, but the original value cannot be recovered from the log — only whether it matched. The specification's remedy is to declare two arguments holding the same value, one indexed for searching and one not indexed for reading, at the cost of larger logs and higher gas. The hash encoding rules themselves leave an ambiguity too: when a struct contains several dynamic arrays, the concatenated byte sequence may not be unique, and the specification warns against judging an event's meaning from the search result of an indexed argument alone.

Finally, “searchable” and “decodable” must be kept apart. A value-type indexed argument can be read out directly, a dynamic-type indexed argument can only be matched and not read out; a non-indexed argument can be read out but not searched by value. Splitting logs into topics and data is a trade-off among log size, search capability, and decoding capability, and no single class of argument gets all of those properties at once.

Sources

  • Solidity documentation, Contract ABI Specification: function selectors, head/tail layout, strict encoding mode, event and indexed argument encoding rules, and the sam decoding example: https://docs.soliditylang.org/en/latest/abi-spec.html
  • The candidate signature list for 0xa9059cbb on 4byte.directory (queried September 2026; entries can be submitted by anyone): https://www.4byte.directory/signatures/?bytes4_signature=0xa9059cbb
  • EIP-609, Hardfork Meta: Byzantium, the meta-proposal that listed EIP-211 and EIP-214 in the Byzantium inclusion list: https://eips.ethereum.org/EIPS/eip-609
  • EIP-2028, Transaction data gas cost reduction, cutting non-zero calldata bytes from 68 gas to 16 gas: https://eips.ethereum.org/EIPS/eip-2028
  • EIP-211, New opcodes: RETURNDATASIZE and RETURNDATACOPY, reading dynamically sized return data and BYZANTIUM_FORK_BLKNUM: https://eips.ethereum.org/EIPS/eip-211
  • Ethereum Yellow Paper, appendix G fee schedule with G_txdatazero at 4 gas and G_txdatanonzero at 16 gas: https://ethereum.github.io/yellowpaper/paper.pdf
  • ethereum.org EVM opcode reference and the wolflo/evm-opcodes dynamic gas table, pricing of zero / non-zero bytes in a transaction's intrinsic gas: https://ethereum.org/en/developers/docs/evm/opcodes/ , https://github.com/wolflo/evm-opcodes/blob/main/gas.md

Further reading

What EVM Compatibility Actually Means: Bytecode, Precompiles, JSON-RPC, and ToolingBitroot Optimistic Parallelization: Detection, Re-execution, and Determinism

For how call instructions relate to context, see 0.17 “The CALL Family Compared: CALL, CALLCODE, DELEGATECALL, and STATICCALL”.

← PreviousSingle-Threaded Global State: The Design Origin of the EVM Performance Bottleneck
Contents
The selector is a hash truncation of the canonical signature, not the function name itselfStatic types fill exactly one wordDynamic types are addressed two levels deep through an offset and a length prefixDecoding a stretch of calldata by handThe ABI is not self-describing; a schema is a mandatory off-chain artifactEvents put searchability in topics and readability in dataSourcesFurther reading
Reading settings