ABI & Contract Interaction
Every interaction with a smart contract — from a frontend button click to a DeFi protocol's internal call — passes through the ABI encoding system. The ABI (Application Binary Interface) defines how data is serialized into bytes for transport between callers and contracts. Misunderstanding ABI encoding is behind several real vulnerabilities: selector collisions, ABI smuggling, and proxy calldata attacks. This lesson covers the complete encoding system with security eyes open throughout.
Application Binary Interface. In traditional software, an ABI defines how compiled programs call each other. In Ethereum, the ABI is the standard that defines how calldata is structured so that a contract can decode which function to call and with what arguments. Without an ABI, calling a contract would require knowing the raw bytecode layout.
Function Selectors — The First 4 Bytes
Every function call begins with a 4-byte selector that tells the contract which function to invoke. The selector is derived entirely from the function signature — the function name and its parameter types, with no spaces and no parameter names:
// Selector = first 4 bytes of keccak256(function signature)
// Signature = "functionName(type1,type2,...)"
// Example: transfer(address,uint256)
keccak256("transfer(address,uint256)")
// = 0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b
// First 4 bytes: 0xa9059cbb ← the selector
// Verify with cast:
// cast sig "transfer(address,uint256)"
// Output: 0xa9059cbb
// Solidity generates the selector at compile time:
bytes4 constant TRANSFER_SELECTOR = bytes4(keccak256("transfer(address,uint256)"));
// Or use abi.encodeWithSignature for dynamic computation:
bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", recipient, amount);ABI Encoding: How Parameters Are Packed
After the 4-byte selector comes the encoded parameters. The ABI encoding rules are precise and worth knowing in detail — they determine exactly how bytes map to Solidity values:
// Static types (fixed size): encoded inline, padded to 32 bytes
// uint256, address, bool, bytes32, int, fixed-size arrays
// Dynamic types: encoded as offset + length + data
// bytes, string, dynamic arrays, structs with dynamic members
// Example: transfer(address recipient, uint256 amount)
// transfer(0xAbCd...1234, 1000000000000000000)
// Calldata breakdown (hex):
0xa9059cbb // selector
000000000000000000000000AbCd1234AbCd1234AbCd1234AbCd1234AbCd1234 // address (padded left)
0000000000000000000000000000000000000000000000000de0b6b3a7640000 // 1e18 = 1 ETH worth
// Dynamic type example: send(address, bytes)
// The 'bytes' parameter is encoded as:
// [offset to data][length of data][data padded to 32-byte boundary]Step-by-Step Calldata Decoding
Let's decode a real piece of calldata byte by byte. This is exactly what an auditor does when reviewing suspicious transactions on Etherscan:
// Raw calldata from a USDC transfer transaction:
0xa9059cbb
000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045
00000000000000000000000000000000000000000000000000000000000F4240
// Byte 0-3: 0xa9059cbb → selector for transfer(address,uint256)
// Byte 4-35: address = 0xd8dA...6045 (padded with 12 zero bytes on left)
// Byte 36-67: uint256 = 0xF4240 = 1,000,000 = 1 USDC (6 decimals)
// Decode using cast:
cast calldata "transfer(address,uint256)" \
0xa9059cbb000000000000000000000000d8dA...00000000000000000000000000000000000F4240
// Output:
// address: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
// uint256: 1000000
// Decode calldata from a live transaction on Etherscan:
// 1. Find tx on Etherscan → "Input Data" tab → "Decode Input Data"
// 2. Or use cast tx <txhash> --rpc-url mainnet | grep inputEncoding Functions in Foundry/Solidity
// abi.encode — encode values without selector (for hashing, storage)
bytes memory encoded = abi.encode(address(0x1234), uint256(100));
// abi.encodePacked — tightly packed (no padding) — for hashing
bytes32 hash = keccak256(abi.encodePacked(msg.sender, tokenId, nonce));
// abi.encodeWithSelector — adds 4-byte selector at front
bytes memory callData = abi.encodeWithSelector(
IERC20.transfer.selector, // 0xa9059cbb
recipient,
amount
);
// abi.encodeWithSignature — computes selector from string
bytes memory callData2 = abi.encodeWithSignature(
"transfer(address,uint256)",
recipient,
amount
);
// abi.decode — decode bytes back to types
(address to, uint256 amt) = abi.decode(encoded, (address, uint256));
// WARNING: abi.encodePacked with variable-length types can collide!
// keccak256(abi.encodePacked("a","bc")) == keccak256(abi.encodePacked("ab","c"))
// Always use abi.encode (with padding) for hashing multiple dynamic typesEvents: Indexed Topics vs Data
Events have their own encoding system. Understanding it matters for both writing efficient events and auditing off-chain monitoring systems:
// Event declaration:
event Transfer(address indexed from, address indexed to, uint256 value);
// When emitted, creates a log with:
// topics[0] = keccak256("Transfer(address,address,uint256)") ← event signature
// topics[1] = keccak256(from) ← indexed param 1 (padded to 32 bytes)
// topics[2] = keccak256(to) ← indexed param 2
// data = abi.encode(value) ← non-indexed params
// Maximum 3 indexed parameters (topics[1,2,3])
// topics[0] is always reserved for the event signature hash
// Indexed = stored in bloom filter → filterable but hashed
// Non-indexed = stored in data → cheap, but requires decoding to filter
// Reading events with ethers.js:
// const filter = token.filters.Transfer(fromAddress, null);
// const events = await token.queryFilter(filter, startBlock, endBlock);When hashing multiple values for signatures or permits, always use abi.encode rather than abi.encodePacked. Packed encoding concatenates values without padding, meaning abi.encodePacked("ab", "c") produces the same bytes as abi.encodePacked("a", "bc"). This collision can let an attacker forge signatures. With abi.encode, each value is padded to 32 bytes, making collisions impossible for different input pairs.
Security Implications
Two different function signatures can produce the same 4-byte selector — there are only 4 billion possible selectors but infinitely many function names. An attacker could register a function collide_xyz() that produces the same selector as transfer(address,uint256), causing the contract to route calls to the wrong function. Always verify there are no selector collisions in your contract using forge inspect MyContract methodIdentifiers.
// ABI smuggling: crafting calldata that passes a signature check
// but executes a different action than intended
// Vulnerable pattern: a multisig checks the selector of inner calldata
function execute(address target, bytes calldata data) external {
// Only allow 'transfer' calls
bytes4 selector = bytes4(data[:4]);
require(selector == TRANSFER_SELECTOR, "not transfer");
// BUG: ABI encoding allows padding bytes that shift where the
// actual selector lands in the decoded struct/tuple
target.call(data);
}
// Safe pattern: decode fully, then re-encode
function safeExecute(address target, address to, uint256 amount) external {
// Decode and re-encode eliminates ABI smuggling
bytes memory data = abi.encodeWithSelector(IERC20.transfer.selector, to, amount);
target.call(data);
}If a proxy contract forwards user-supplied calldata via delegatecall without validation, an attacker can call any function in the implementation — including admin functions like upgradeTo() — by crafting calldata with those selectors. The Parity Wallet hack exploited this pattern to drain $150 million in ETH. Never use delegatecall with unvalidated caller-provided calldata.
# Fetch ABI of USDC on mainnet (verified on Etherscan)
cast interface 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain mainnet
# Compute a selector
cast sig "transfer(address,uint256)"
# 0xa9059cbb
# Check for selector collisions in your project
forge inspect MyToken methodIdentifiers
# Decode real calldata from a transaction
cast calldata-decode "transfer(address,uint256)" \
0xa9059cbb000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA960450000000000000000000000000000000000000000000000000000000000F4240
# Look up unknown selector in 4byte.directory
cast 4byte 0xa9059cbb
# transfer(address,uint256)Comparison: Encoding Methods
| Function | Includes Selector? | Padding? | Use Case | Collision Risk? |
|---|---|---|---|---|
| abi.encode | No | Yes (32-byte aligned) | Hashing, decoding | No |
| abi.encodePacked | No | No (tight) | Hashing single type | Yes (dynamic types) |
| abi.encodeWithSelector | Yes (explicit) | Yes | Low-level calls | No |
| abi.encodeWithSignature | Yes (computed) | Yes | Low-level calls | No |
| abi.encodeCall | Yes (type-safe) | Yes | Type-safe low-level | No |
Key Takeaways
- Function selectors are the first 4 bytes of calldata — the keccak256 of the function signature. Know how to compute and verify them with
cast sig. - ABI encoding pads every parameter to 32 bytes; dynamic types add an offset pointer and a length field.
abi.encodePackedskips padding and can cause hash collisions with multiple dynamic arguments. - Selector collisions are rare but real — always audit contracts with many functions using
forge inspect methodIdentifiers. - ABI smuggling exploits the gap between what a signature validation layer checks and what the target contract actually decodes — decode fully then re-encode to eliminate this class of bug.
- Events use a 4-topic log structure;
indexedparameters are stored as topics (searchable but hashed), non-indexed go into data (cheap but not filterable). - Reading calldata with
cast calldata-decodeandcast 4byteis an essential auditor skill for analyzing suspicious on-chain transactions.