On decoding UserOps
If you've ever tried to analyze Account Abstraction (AA) transactions on Dune Analytics, you've probably hit a wall. Dune is fantastic for most blockchain analytics, but when it comes to decoding complex 4337 smart account calldata, it falls short.
Here's the challenge: Dune can't natively decode EntryPoint v7 handleOps calls. These transactions contain deeply nested PackedUserOperation[]
arrays, each with their own callData that needs further decoding.
In order to go about this limitation we would need to the following:
Raw calldata (Dune) — grab with Dune API
Local decoding (using Viem library) — the main challenge
Clean data (CSV) — should contain
Back to Dune — as a Dune dataset
Create whatever charts you want
Extracting calldata
We need a query on Dune that extracts calldata. We will then create an API endpoint from that very query to grab this calldata and create locally a calldata.json
file.
Here, we extract 100 rows of EntryPoint v7 calldata on Ethereum network:
SELECT
block_time,
hash AS tx_hash,
data AS calldata
FROM
ethereum.transactions
WHERE
"to" = 0x0000000071727de22e5e9d8baf0edac6f37da032
AND block_time >= TIMESTAMP '2025-01-01'
AND block_number BETWEEN {{start_block_number}} AND {{end_block_number}}
LIMIT 100
You’ll notice that I’ve included place holders for block numbers. You don’t need to do that:
SELECT
block_time,
hash AS tx_hash,
data AS calldata
FROM
ethereum.transactions
WHERE
"to" = 0x0000000071727de22e5e9d8baf0edac6f37da032
AND block_time BETWEEN TIMESTAMP '2025-01-01 00:00:00' AND TIMESTAMP '2025-06-01 00:00:00'
ORDER BY block_time DESC
LIMIT 100
But if we want to go multichain, it’s best to use block time for simplicity:
SELECT * FROM (
-- Ethereum
SELECT * FROM (
SELECT
'ethereum' AS network,
block_time,
block_number,
hash AS tx_hash,
"to",
data AS calldata
FROM ethereum.transactions
WHERE "to" = 0x0000000071727de22e5e9d8baf0edac6f37da032
AND block_time BETWEEN TIMESTAMP '2025-01-01 00:00:00' AND TIMESTAMP '2025-06-01 00:00:00'
ORDER BY block_time DESC
LIMIT 1000
)
UNION ALL
-- Base
SELECT * FROM (
SELECT
'base' AS network,
block_time,
block_number,
hash AS tx_hash,
"to",
data AS calldata
FROM base.transactions
WHERE "to" = 0x0000000071727de22e5e9d8baf0edac6f37da032
AND block_time BETWEEN TIMESTAMP '2025-01-01 00:00:00' AND TIMESTAMP '2025-06-01 00:00:00'
ORDER BY block_time DESC
LIMIT 1000
)
UNION ALL
-- Arbitrum
SELECT * FROM (
SELECT
'arbitrum' AS network,
block_time,
block_number,
hash AS tx_hash,
"to",
data AS calldata
FROM arbitrum.transactions
WHERE "to" = 0x0000000071727de22e5e9d8baf0edac6f37da032
AND block_time BETWEEN TIMESTAMP '2025-01-01 00:00:00' AND TIMESTAMP '2025-06-01 00:00:00'
ORDER BY block_time DESC
LIMIT 1000
)
)
ORDER BY block_time DESC
LIMIT 3000;
We pull this into calldata.json (should be something like below) and start decoding.
Actually decoding
We’ll need a main decoder that orchestrates the entire calldata decoding process for AA transactions. So our main decoder acts as a traffic controller that:
Detects which AA standard is being used via function selector
Routes to the appropriate specialized decoder script we have (ERC7579, Alchemy, SmartVault)
Handles the "zero address trick" where real targets are nested deeper
Single Execute: Direct function calls
Single execute is the straightforward approach where a smart wallet makes exactly one function call to exactly one target contract.
The function selector for execute is the first 4 bytes of the calldata. This is calculated as the first 4 bytes of the Keccak-256 hash of the function signature:execute(address target,uint256 value,bytes data)
After the function selector, the parameters are encoded in this order:
target (address, 32 bytes): The contract address to call
value (uint256, 32 bytes): The amount of ETH to send with the call
callData (bytes, dynamic): The calldata to send to the target contract
This predictable structure makes single execute transactions both gas-efficient and easy to decode.
Decoding ZeroDev example
We have the following event if calltype is execute
(0x00) with 0x01 being decodeBatch
:
if (callType === "0x00") {
// Single‐call path unchanged
const targetAddress = `0x${executionCalldata.slice(2, 42)}`;
const value = BigInt(`0x${executionCalldata.slice(42, 106)}`);
const callData = `0x${executionCalldata.slice(106)}`;
console.log("Single call →");
console.log(" Target: ", targetAddress);
console.log(" Value: ", value.toString());
console.log(" CallData:", callData);
And have the following calldata:
const data =
"0xe9ae5c53000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000078c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000095ea7b30000000000000000000000001e0049783f008a0085193e00003d00cd54003c7100000000000000000000000000000000000000000000000000000000000000000000000000000000";
We get:
If you want to play around manually/locally yourself, here’s the ERC7579 single execute script.
Batch Execute: Orchestrated multi-call operations
Batch execute is where a smart wallet can perform multiple distinct operations within a single transaction. This is particularly powerful for complex DeFi operations that require multiple steps, such as swapping tokens and then staking the result.
The technical complexity of batch execute lies in its data structure. Instead of a simple linear arrangement of parameters, batch execute uses an array of execution tuples, where each tuple contains a target address, value, and call data. 0x[function_selector][offset_to_array][array_length][struct1_target][struct1_value][struct1_data_offset][struct1_data_length][struct1_calldata][struct2_target][struct2_value][struct2_data_offset][struct2_data_length][struct2_calldata]...
The executeBatch
packs multiple execution structs into a single array, with each struct containing the same three fields (target, value, callData) as a single execute call.
When decoding batch execute transactions, the process becomes more involved because we're dealing with a variable-length array rather than fixed offsets. The decoder must first understand the ABI structure, then parse the array length, and finally iterate through each execution tuple to extract the individual target addresses, values, and call data.
Decoding Alchemy example
We have the following event if we do discover executeBatch
:
case "executeBatch": {
const raw = args[0] as Array<{
target: string;
value: bigint;
data: `0x${string}`;
}>;
return raw.map(({ target, value, data }) => ({
target,
value,
callData: data,
}));
}
And have the following calldata:
const data = "0x34fcd5be0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000040b35800bb3e536aee3dc5dbd46d8f0a39c4dffc000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000039008584ef5a94fba2d6d27669429ab47c1fc8e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
// Decode & print
const calls = decodeAlchemyAccountCall(data as `0x${string}`);
console.log(`Decoded ${calls.length} call(s):`);
calls.forEach((c, i) => {
console.log(`\nCall #${i + 1}`);
console.log(" target: ", c.target);
console.log(" value: ", c.value.toString());
console.log(" callData:", c.callData);
});
And outputs us:
Though the target address are the same, the calldata isn’t though it despite it looking near identical. Sometimes there may even be more than two calls. Here’s the script for Alchemy.
But wait, how do I manually check for ops.calldata?
Well, let’s still take this Alchemy batch case. From the tx hash: 0x59e64f302d912a7f8e091ff4aea503d1628f4973dd793183554a46c631f95b38
, go to etherscan.io, scroll down to input data, select view input as original & decode input data.
It should look like the above.
Dealing with various implementations and functions selectors
Now we need to keep in mind that we need to handle three main “types” of implementation: ERC7579, ERC6900, and custom implementations.
I’ve made this very small script to calculate function selectors, view here! It’s definitely not a fully curated list, but a good starting point.
By taking function signatures (like execute(address,uint256,bytes)
) and converting them to their 4-byte hex selectors (like 0x34fcd5be) we can near instantly know which function selector corresponds to which AA implementation. For instance, the two most common ones are:
Alchemy (ERC6900):
execute(address,uint256,bytes)
→ 0xb61d27f6 (single execute)
ZeroDev (ERC7579):
execute(bytes32,bytes)
→ 0xe9ae5c53 (single execute)
Technically you don’t have to decode function selectors separately like this. But when you see a function selector in calldata, you can easily use this mapping to determine which AA standard is being used. And will also prevent any surprises when you find overlapping function selectors between smart accounts.
Conclusion
After processing and decoding, we create a decoded_results.csv
. And that’s how you get target addresses!
To fully utilize this data, we would need to upload this to Dune and create custom queries on this dataset which should be something like dune.username.name_of_dataset
.
Though the full implementation details aren’t here, I do hope this little writeup is useful to someone one day! Personally, this was a steep learning curve which wrecked my brain a bit haha.
Feel free to DM me or reply below if you have any feedback/comments.
~ ta ta