Want to Read Smart Wallet Transactions? You’ll Need to Hack Around Dune



This content originally appeared on HackerNoon and was authored by bithiah

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:

  1. Raw calldata (Dune) — grab with Dune API
  2. Local decoding (using Viem library) — the main challenge
  3. Clean data (CSV) — should contain
  4. Back to Dune — as a Dune dataset
  5. 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:

  1. target (address, 32 bytes): The contract address to call
  2. value (uint256, 32 bytes): The amount of ETH to send with the call
  3. callData (bytes, dynamic): The calldata to send to the target contractThis 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


This content originally appeared on HackerNoon and was authored by bithiah