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. Dune can't natively decode EntryPoint v7 handleOps calls. PackedUserOperation[] 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 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. calldata.json 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 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 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; 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 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: 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) 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 contractThis predictable structure makes single execute transactions both gas-efficient and easy to decode. target (address, 32 bytes): The contract address to call target value (uint256, 32 bytes): The amount of ETH to send with the call value callData (bytes, dynamic): The calldata to send to the target contractThis predictable structure makes single execute transactions both gas-efficient and easy to decode. callData Decoding ZeroDev example Decoding ZeroDev example We have the following event if calltype is execute (0x00) with 0x01 being decodeBatch: execute 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); 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"; const data = "0xe9ae5c53000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000078c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000095ea7b30000000000000000000000001e0049783f008a0085193e00003d00cd54003c7100000000000000000000000000000000000000000000000000000000000000000000000000000000"; We get: If you want to play around manually/locally yourself, here’s the ERC7579 single execute script. ERC7579 single execute script Batch Execute: Orchestrated multi-call operations 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]... 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. executeBatch 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 Decoding Alchemy example We have the following event if we do discover executeBatch: 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, })); } 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); }); 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. script for Alchemy But wait, how do I manually check for ops.calldata? 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. 0x59e64f302d912a7f8e091ff4aea503d1628f4973dd793183554a46c631f95b38 etherscan.io It should look like the above. Dealing with various implementations and functions selectors 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. ERC7579 ERC6900 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. here 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: execute(address,uint256,bytes) Alchemy (ERC6900): execute(address,uint256,bytes) → 0xb61d27f6 (single execute) ZeroDev (ERC7579): execute(bytes32,bytes) → 0xe9ae5c53 (single execute) Alchemy (ERC6900): execute(address,uint256,bytes) → 0xb61d27f6 (single execute) Alchemy (ERC6900) execute(address,uint256,bytes) ZeroDev (ERC7579): execute(bytes32,bytes) → 0xe9ae5c53 (single execute) ZeroDev (ERC7579) execute(bytes32,bytes) 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! decoded_results.csv 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. upload 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. me ~ ta ta