Decoding Smart Contract Calldata: From Hex to Readable Arguments
Open any Ethereum transaction on Etherscan, scroll down to the Input Data field, and you usually see a wall of hex that starts with something like 0x23b872dd followed by several hundred more characters. That blob is calldata. It is what your wallet actually sent to the contract, and it is the only thing the EVM saw when it decided whether to move your tokens, mint your NFT, or revert with an error. If you cannot read it, you are trusting whatever the front-end told you the transaction would do.
The good news is that calldata is a well-specified format. Once you understand the layout, you can decode almost any call by hand with nothing more than the ABI and a hex viewer. This guide walks through the format step by step, using real function signatures and real selectors, and shows where the free decoders on BeautiCode save time when you do not have the ABI handy.
I first sat down to decode calldata by hand after a support ticket came in claiming a contract had "stolen" the user's tokens. The transaction had succeeded, gas was burned, and the front-end showed no errors. Reading the calldata made it obvious: the user had signed an approve with an allowance of 2**256 - 1 a week earlier and forgotten about it. No amount of front-end screenshots would have surfaced that — only the raw bytes did.
What calldata actually looks like
Calldata is the raw byte string sent in the data field of an Ethereum transaction. For a plain ETH transfer it is empty. For any contract call it follows a fixed layout defined by the Solidity / Contract ABI specification:
[ 4-byte function selector ][ 32-byte aligned encoded arguments ] selector | arg 1 (32 bytes) | arg 2 (32 bytes) | ... 0x23b872dd | 000...from address | 000...to address | ...
The first four bytes are the function selector: the leftmost 4 bytes of keccak256("functionName(type1,type2,...)"). Everything after that is the arguments, encoded in 32-byte chunks. Static types (address, uint256, bool, fixed-size integers and bytes) are written directly, left-padded with zeros to 32 bytes. Dynamic types (bytes, string, arrays) are written as a 32-byte offset to where the actual data lives further down in the calldata.
A concrete example helps. This is the calldata for a real USDC transfer of 123.456789 USDC (USDC has 6 decimals, so the raw amount is 123456789):
0xa9059cbb
000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045
0000000000000000000000000000000000000000000000000000000007596b55
selector : 0xa9059cbb = keccak256("transfer(address,uint256)")[0:4]
arg 0 (to): 0xd8dA...6045 (the address, left-padded with 12 zero bytes)
arg 1 (v) : 0x07596b55 = 123,456,789 (decimal)Total length: 4 + 32 + 32 = 68 bytes, which is 2 + 136 = 138 hex characters including the 0x prefix. Every ERC-20 transfer in history has exactly this layout. Once you can see the shape, you can read it.
Step 1: Resolve the function selector
The first thing I do with any unknown calldata is isolate the first 4 bytes and figure out which function they correspond to. The mapping from selector to signature is one-way by design (you cannot reverse a hash), so in practice you look it up in a registry.
The community-maintained 4byte.directory has around 1 million signatures submitted by developers over the years, and it resolves the vast majority of mainnet selectors. Here are a few that are worth memorizing because they show up in almost every block:
0xa9059cbb transfer(address,uint256) 0x23b872dd transferFrom(address,address,uint256) 0x095ea7b3 approve(address,uint256) 0x70a08231 balanceOf(address) 0x18160ddd totalSupply() 0xdd62ed3e allowance(address,address) 0x40c10f19 mint(address,uint256) 0x42842e0e safeTransferFrom(address,address,uint256) // ERC-721 0xf242432a safeTransferFrom(address,address,uint256,uint256,bytes) // ERC-1155 0xac9650d8 multicall(bytes[]) // Uniswap v3 / many routers 0x3593564c execute(bytes,bytes[],uint256) // Uniswap Universal Router 0x5ae401dc multicall(uint256,bytes[]) // Uniswap v3 with deadline
If you just have the signature string and want the selector, you can compute it directly with Keccak-256. BeautiCode's Function Selector tool takes a Solidity function signature and returns the 4-byte selector instantly — useful when you are writing low-level calls, debugging assembly, or verifying that a signature you saw on a block explorer matches what a contract actually exposes.
One trap worth calling out: selectors are computed from the canonical signature, which means no variable names and no spaces. The signature transfer(address to, uint256 amount) hashes to a completely different selector than transfer(address,uint256). Tuples and structs also matter: a struct is flattened to its component types in the canonical form, which is why you occasionally see signatures like exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160)) in Uniswap v3.
Step 2: Match the selector to an ABI
A selector alone tells you the function signature, which is enough to know the argument types. What it does not give you is the argument names or any surrounding context about what the function does. For that you want the full ABI.
You are in one of three situations here:
- You own the contract. The compiler output (Hardhat, Foundry, solc) includes the ABI as JSON. Grab it from
artifacts/orout/and you are done. - The contract is verified on a block explorer. Etherscan, Basescan, Arbiscan, and the rest expose the ABI under the Contract tab of any verified address. Copy the JSON and paste it into your decoder.
- The contract is unverified. This happens with fresh deployments, honeypot scams, and some private deployments. You only have the selector. In that case the best you can do is look up the signature on 4byte.directory and decode blindly against the inferred types. Treat the result with some skepticism — in rare cases two different functions hash to the same selector (a collision), and without a full ABI you cannot tell them apart.
When I audit a transaction I cannot recognize, I check Etherscan first. If the contract is verified, I paste the ABI and calldata into BeautiCode's ABI Decoder and read the result. If the contract is not verified, I fall back to the Calldata Decoder, which resolves the selector through 4byte.directory and decodes against that signature. It is the same tool I use when I want a quick sanity check before signing any non-trivial transaction in my own wallet.
One more nuance about ABIs: the JSON you paste into a decoder only needs to describe the function you are decoding, not the entire contract. If the Etherscan ABI is enormous (some protocols ship hundreds of functions in a single JSON), you can trim it down to a single entry. The decoder looks up the selector against the entries you provide, so a one-function ABI is fine. This is handy when you want to share a calldata snippet with a teammate without leaking the rest of the interface, and it makes the output easier to read because there is only one possible match.
Proxy contracts deserve a brief mention too. Most large protocols deploy behind a proxy, so the address you call has a tiny delegatecall dispatcher and the logic lives at a separate implementation address. Etherscan will automatically surface the implementation ABI when you open the proxy, which is what you want for decoding the calldata. If it does not — some custom proxies confuse the detector — navigate to the implementation address directly and pull the ABI from there. The calldata format is identical either way; only the storage location of the code differs.
Step 3: Decode the arguments
With a function signature in hand, splitting the rest of the calldata into 32-byte words is mechanical. Here are three real examples you can paste into the Calldata Decoder and work through line by line.
Example 1 — ERC-20 transferFrom
transferFrom(address from, address to, uint256 amount) has selector 0x23b872dd. Every argument is a fixed-size static type, so the calldata is exactly 4 + 3×32 = 100 bytes long:
0x23b872dd
0000000000000000000000001111111254eeb25477b68fb85ed929f73a960582
000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045
0000000000000000000000000000000000000000000000000de0b6b3a7640000
selector : transferFrom(address,address,uint256)
from : 0x1111111254EEB25477B68fb85Ed929f73A960582 (1inch v4 router)
to : 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 (vitalik.eth)
amount : 0x0DE0B6B3A7640000 = 1_000_000_000_000_000_000 = 1e18
(1 token, if the token has 18 decimals)Example 2 — ERC-20 approve
approve(address spender, uint256 value) has selector 0x095ea7b3. This is the function every wallet phishing kit likes best, because one careless signature gives the spender permission to drain your token balance at any point in the future. Read the value carefully:
0x095ea7b3 0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff selector : approve(address,uint256) spender : 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D (Uniswap v2 Router) value : 2**256 - 1 (aka "infinite approval", type(uint256).max)
That bottom line — 64 f's in a row — is the pattern to recognize. Any time you are about to sign an approve whose decoded value looks like that, stop and confirm you actually meant to grant an unlimited allowance to that specific spender. This is the single highest value payoff you get from learning to read calldata.
Example 3 — Uniswap-style multicall with a nested swap
multicall(bytes[]) has selector 0xac9650d8. Because its only argument is a dynamic bytes[], the layout is more involved: you get a 32-byte offset, then at that offset the array length, then for each element another offset, then the element bytes themselves. Schematically:
0xac9650d8 0000000000000000000000000000000000000000000000000000000000000020 // offset to array (0x20 = 32) 0000000000000000000000000000000000000000000000000000000000000002 // array length = 2 0000000000000000000000000000000000000000000000000000000000000040 // offset to bytes[0] 00000000000000000000000000000000000000000000000000000000000000c0 // offset to bytes[1] ..... bytes[0] length + padded data ..... ..... bytes[1] length + padded data ..... each bytes[i] is itself a full calldata payload for another function on the same contract — usually exactInput / exactOutput swaps, plus a final sweepToken or unwrapWETH9.
The important practical point is that multicall is a container. The thing you actually want to understand is what each inner element does, so you decode the outer call, pull out each bytes element, and feed it back into the decoder as a new calldata input. The Calldata Decoder handles this recursively on pretty much any router I have thrown at it.
Common pitfalls
Three categories of calldata routinely catch people out. Knowing them up front saves a lot of confused staring.
Overloaded functions
Solidity allows two functions in the same contract to share a name if their parameter lists differ. Each one gets its own selector. ERC-721 is the classic source of this:
safeTransferFrom(address,address,uint256) → 0x42842e0e safeTransferFrom(address,address,uint256,bytes) → 0xb88d4fde
Same function name, different selectors, different calldata length. The selector is always the source of truth — never try to guess which overload was called from the name alone.
Tuples and structs are flattened
Solidity structs do not exist at the ABI level. They get rewritten as tuples of their component types for the purpose of computing the selector and encoding calldata. A struct with three fields (address,uint256,bool) appears in the signature as exactly that tuple. If the tuple is entirely static types, it is packed inline like any other static data. If any field is dynamic (bytes, string, T[]), the whole tuple is treated as dynamic and placed at an offset. This is the single most common source of bugs I see when people try to hand-roll calldata: they forget to add the tuple-level offset and the decoder returns garbage.
Dynamic types and the offset trick
Strings, bytes, and arrays all use the same trick. At the slot where the argument "would" go, the calldata instead contains a 32-byte offset measured from the start of the argument area (i.e. right after the selector). At that offset you find the length, then the data, padded to a multiple of 32 bytes. A call to setGreeting(string) with the value "hello" looks like:
0xa4136862 // selector 0000000000000000000000000000000000000000000000000000000000000020 // offset to string (32) 0000000000000000000000000000000000000000000000000000000000000005 // length = 5 68656c6c6f000000000000000000000000000000000000000000000000000000 // "hello" + 27 zero bytes
The 27 trailing zeros are there because dynamic data is right-padded to the next 32-byte boundary. Get this wrong and the EVM will read past your data into whatever follows. This is also where malicious calldata sometimes hides extra bytes that a naive decoder skips over; a careful decoder reports the total length and flags trailing bytes as unexpected.
The offset is always measured from the start of the argument area — immediately after the selector — and not from the start of the entire calldata. This trips up most first-time implementers of manual encoding. If you are reading calldata that was encoded by a non-Solidity tool (a Rust library, a Go SDK, a hand-rolled script), double-check the offsets before trusting the decoded output. An off-by-4 bug here is surprisingly common and will silently produce plausible-looking garbage.
Packed vs. standard encoding
Solidity exposes two encoding helpers: abi.encode and abi.encodePacked. Only the first produces calldata compatible with the decoders described here. Packed encoding concatenates values without padding or length prefixes, which saves gas but makes the result ambiguous — two different argument sets can produce identical packed bytes. It shows up in signature hashing (EIP-712 is standard encoding; some older signing schemes used packed) and in tightly optimized inline assembly, but it is almost never what you find in a transaction's top-level data field. If a decode looks obviously wrong and the source used encodePacked, that is your culprit.
When you need the event logs too
Calldata tells you what was sent in. Event logs tell you what came out. For anything involving transfers or state transitions, the two are complementary: the calldata confirms the intent, and the logs confirm the effect. A successful transaction can still produce surprising logs, and log decoding is how you catch that.
Each log entry has up to four 32-byte topics and a variable-length data field. The first topic is always keccak256("EventName(type1,type2,...)") — the same pattern as function selectors, but 32 bytes instead of 4. Indexed parameters go into the remaining topics; non-indexed parameters are ABI-encoded in the data field using exactly the same rules as function arguments.
The two event topic hashes worth committing to memory are:
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef Transfer(address indexed from, address indexed to, uint256 value) 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925 Approval(address indexed owner, address indexed spender, uint256 value)
If you are reading a transaction that claims to be a simple token transfer, you should see exactly one Transfer event whose from and to match the calldata. If you see more Transfer events than you expected — or from tokens you did not mention in the calldata — you are probably looking at a swap, an auto-compounder, or something less friendly. The Event Log Decoder takes topics + data + an event signature and returns the named arguments, the same way the calldata decoder does for function calls.
Reading a real transaction end-to-end
Let's walk through a concrete scenario I see constantly: a user swaps USDC for WETH on Uniswap v3 through the SwapRouter. On Etherscan this shows up as a single transaction with a short input that starts with 0xac9650d8. A shaped, shortened version of the calldata looks like this:
Outer call ───────────────────────────────────────────────────────────────────── 0xac9650d8 // multicall(bytes[]) 00...0020 // offset to array 00...0002 // 2 inner calls 00...0040 // offset to bytes[0] 00...0160 // offset to bytes[1] bytes[0] — the actual swap ───────────────────────────────────────────────────────────────────── 0x04e45aaf // exactInputSingle(ExactInputSingleParams) 000...a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 // tokenIn = USDC 000...c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 // tokenOut = WETH 000...0000000000000000000000000000000000000000bb8 // fee = 3000 (0.3%) 000...d8da6bf26964af9d7eed9e03e53415d37aa96045 // recipient= vitalik.eth 000...0000000000000000000000000000000000000005f5e100 // amountIn = 100_000000 = 100 USDC 000...0000000000000000000000000000000000000b22fe6a6 // amountOutMinimum ≈ 0.0478 WETH 000...0 // sqrtPriceLimitX96 = 0 (no limit) bytes[1] — refund any leftover ETH (always paired with multicall swaps that wrap ETH) ───────────────────────────────────────────────────────────────────── 0x12210e8a // refundETH() (no arguments)
Here is what that tells you without ever opening the Uniswap UI:
- It is a multicall, so the user signed one transaction that bundles multiple contract calls.
- The first inner call is a single-hop exact-input swap on the 0.3% USDC/WETH pool.
- Input is 100 USDC (6 decimals → raw
5f5e100), the minimum acceptable output is about 0.0478 WETH (18 decimals), and the recipient is the user's own address. - The second inner call refunds any leftover ETH. That makes sense because Uniswap's SwapRouter expects you to send exactly as much ETH as you want to swap via msg.value; any surplus gets stuck otherwise.
To confirm the swap actually went through at the expected rate, you cross-check against the event logs. There should be a Swap event from the USDC/WETH pool, plus two Transferevents — one for USDC out of the user and one for WETH into the user — whose amounts match the swap. Any discrepancy between the decoded calldata and the decoded logs is where the surprises live. This is the same flow I run through whenever a support ticket claims a transaction "did the wrong thing." It takes two minutes with the right decoders and it tells you with certainty what the contract saw and what it emitted.
Wrapping up
Calldata looks opaque from the outside but it follows simple rules: 4-byte selector, then 32-byte aligned arguments, with dynamic types referenced by offset. Once you can split a hex blob into the selector and the 32-byte words that follow, everything else is just looking up types and reading numbers.
The fastest path from a raw transaction to a readable call is:
- Copy the Input Data from Etherscan (or the
datafield from any RPC response). - Paste it into the Calldata Decoder for automatic selector lookup, or the ABI Decoder if you already have the contract's ABI JSON.
- If something is still ambiguous, compute the selector directly with the Function Selector tool and compare.
- Pull the transaction logs and pass them through the Event Log Decoder to cross-check that the output matches the intent.
All four tools run in the browser, never upload your data to a server, and cost nothing. If you are doing any amount of on-chain work — wallet integration, contract auditing, user support for a dApp — having them open in a tab saves a real amount of time. And if you would like more background on the chain itself, the companion post on Ethereum blockchain developer basics covers addresses, gas, and the EVM execution model that calldata eventually hits.
The first time you decode a suspicious approve by hand and spot the ffff...ff allowance before signing, the whole exercise pays for itself.
Related Tools
Calldata Decoder
Paste transaction input data and decode function name, parameters, and argument types — no RPC needed.
ABI Decoder & Encoder
Decode and encode Solidity function call data against an ABI. Supports tuples, arrays, and custom types.
Function Selector Lookup
Look up 4-byte function selectors (0x23b872dd → transferFrom) from a local 4byte-directory database.
Event Log Decoder
Decode ERC-20 Transfer, ERC-721 Approval, and other event logs by Topic0 and an ABI event fragment.
Related Articles
How to Generate Secure Passwords in 2026: A Complete Guide
Learn why strong passwords matter and how to generate secure passwords using entropy, length, and complexity. Includes practical tips and free tools.
2025-12-15 · 8 min readData FormatsJSON vs YAML: When to Use What — A Developer's Guide
Compare JSON and YAML formats with syntax examples, pros and cons, and use case recommendations for APIs, configs, and CI/CD pipelines.
2025-12-28 · 10 min read