---
id: 27
title: "ABI in Detail: Selectors, Static Arguments, and Dynamic Types"
slug: 0-15-abi-internals
date: 2026/10/02
summary: "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."
keywords: ABI encoding,function selector,calldata decoding,event logs,topics
heroImage: /images/articles/photos/0-15-abi-internals.jpg
---

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.

```solidity
// 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.

```text
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.

```python
# 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 Tooling"](/en/blog/evm-compatibility-explained)
- ["Bitroot Optimistic Parallelization: Detection, Re-execution, and Determinism"](/en/blog/bitrootevm-)

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