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:
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 100You’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 100But 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.
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:
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:
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 operationsBatch 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 exampleWe 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 selectorsNow 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:
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.
ConclusionAfter 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
All Rights Reserved. Copyright , Central Coast Communications, Inc.