You're staring at a transaction on Etherscan. The "Input Data" field is a
wall of hex. You don't have the contract's ABI. Now what?
Turns out, you can decode most calldata without an ABI at all. Here's how
it works.
The structure of calldata
Every contract call follows the same format:
0x + [4 bytes function selector] + [ABI-encoded parameters]
The first 4 bytes are the keccak256 hash of the function signature,
truncated. For example:
transfer(address,uint256)
→ keccak256 → 0xa9059cbb...
→ first 4 bytes → 0xa9059cbb
The rest is parameters encoded according to the Solidity ABI spec — uint256
is 32 bytes zero-padded, address is 20 bytes left-padded to 32, dynamic
types get an offset pointer, etc.
Step 1: Extract the selector
const calldata =
'0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045
0000000000000000000000000000000000000000000000000de0b6b3a7640000'
const selector = calldata.slice(0, 10) // '0xa9059cbb'
const params = calldata.slice(10) // everything after
That's it. 4 bytes = 8 hex chars + the 0x prefix = 10 characters.
Step 2: Look up the signature
The 4byte.directory maintains a database of known function signatures
mapped to their selectors. It's a free API:
GET https://www.4byte.directory/api/v1/signatures/?hex_signature=0xa9059cbb
Response:
{
"results": [
{ "text_signature": "transfer(address,uint256)" }
]
}
Now you know the function name and parameter types — without ever seeing
the ABI.
Step 3: Decode the parameters
Once you have the signature transfer(address,uint256), you know the
parameter types. ABI decoding is mechanical:
- address → take 32 bytes, last 20 bytes are the address
- uint256 → take 32 bytes, interpret as big-endian integer
// Raw params (after selector):
// 000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045
// 0000000000000000000000000000000000000000000000000de0b6b3a7640000
// Decoded:
// → address: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
// → uint256: 1000000000000000000 (1 ETH in wei)
The annoying parts
This is straightforward for simple calls, but it gets messy fast:
- Collision: Multiple functions can share a selector. transfer(address,uint256) has 3 entries in 4byte.directory. You need to try decoding with each candidate and see which one doesn't throw.
- Dynamic types: string, bytes, and arrays use offset pointers. You have to follow the pointer to find the actual data, then read a length prefix, then the data.
- Nested tuples: Solidity structs encode as tuples with recursive offset pointers. A function like exactInputSingle((address,address,uint24,address,uint256,uint256,uint160)) has nested encoding.
- No match: Custom errors, proxy calls, or recently deployed contracts might not be in 4byte.directory yet.
Putting it together
I wrote a package that handles all of this — selector lookup, candidate
ranking, ABI decoding with dynamic types, and fallback handling:
import { decodeCalldata } from '@pulsadev/tx-decoder'
const decoded = await decodeCalldata(
'0xa9059cbb000000000000000000000000d8da6bf269...'
)
console.log(decoded)
// {
// name: 'transfer',
// signature: 'transfer(address,uint256)',
// selector: '0xa9059cbb',
// args: ['0xd8dA6BF2...', 1000000000000000000n]
// }
It also handles event logs:
import { decodeLog } from '@pulsadev/tx-decoder'
const event = await decodeLog({
topics: ['0xddf252ad...', '0x000...sender', '0x000...receiver'],
data: '0x0000...amount'
})
// { name: 'Transfer', args: [...] }
And error data from reverted transactions:
import { decodeError } from '@pulsadev/tx-decoder'
decodeError('0x08c379a0...')
// 'Insufficient balance'
Zero dependencies, ~25 KB. Works with any RPC — you don't need ethers or
viem.
The package is @pulsadev/tx-decoder if you want to look at the source. It's
MIT, and the src/ is included in the npm package so you can read every
line.
Ethan Park — Senior Engineer at Pulsa Dev. We build zero-dependency EVM
primitives.
Top comments (0)