🌿 Intermediate ⏱️ 30 min

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.

📖 What ABI Stands For

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:

🔢 How Function Selectors Are Computed
// 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:

📦 ABI Encoding Rules by Type
// 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:

🔍 Decoding Calldata — Full Walkthrough
// 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 input

Encoding Functions in Foundry/Solidity

🔧 abi.encode, abi.encodeWithSelector, abi.encodeWithSignature
// 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 types

Events: 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 Encoding — Topics and Data
// 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);
💡 Tip: Use abi.encode Not abi.encodePacked for Hashing Multiple Values

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

🚨 Selector Collision Attack

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 — Malicious Encoded Calldata
// 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); }
⚠️ Delegatecall with User-Supplied Calldata

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.

✏️ Try It — Inspect a Live Contract's ABI
# 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

FunctionIncludes Selector?Padding?Use CaseCollision Risk?
abi.encodeNoYes (32-byte aligned)Hashing, decodingNo
abi.encodePackedNoNo (tight)Hashing single typeYes (dynamic types)
abi.encodeWithSelectorYes (explicit)YesLow-level callsNo
abi.encodeWithSignatureYes (computed)YesLow-level callsNo
abi.encodeCallYes (type-safe)YesType-safe low-levelNo

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.encodePacked skips 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; indexed parameters are stored as topics (searchable but hashed), non-indexed go into data (cheap but not filterable).
  • Reading calldata with cast calldata-decode and cast 4byte is an essential auditor skill for analyzing suspicious on-chain transactions.