Skip to content

About

UserOperation calldata decoder

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

userop-explorer

A terminal tool for decoding Ethereum calldata. Paste any hex blob and get back a navigable tree of what it actually does — including everything nested inside it: multicalls, Safe batches, ERC-4337 user operations, router swap plans, all the way down.

Built with Bun, TypeScript, viem and OpenTUI.

the TUI decoding a UserOp bundle

Usage

bun install
bun start

That opens the TUI: paste calldata, hit enter, and use the arrow keys to walk the tree. Branches collapse and expand, the mouse wheel scrolls, and the optional target field adds token symbols and dollar values.

There's also a one-shot mode for scripts and pipes:

bun start 0x82ad56cb000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000008fb952916d96b4f4467d5bf4f33b6807803e357900000000000000000000000000000000000000000000000000000000017d784000000000000000000000000000000000000000000000000000000000

…which prints:

aggregate3((address target, bool allowFailure, bytes callData)[] calls)  [bundled ✓]
selector 0x82ad56cb · 324 bytes
└─ calls tuple[]
   └─ [0] (address,bool,bytes)
      ├─ target address = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48  · USDC (mainnet)
      ├─ allowFailure bool = false
      └─ callData bytes = 0xa9059cbb…017d7840  · 68 bytes
         ↳ decoded:
           transfer(address to, uint256 amount)  [bundled ✓]
           selector 0xa9059cbb · 68 bytes · to USDC · USD Coin (0x…3606eB48)
           ├─ to address = 0x8fb952916D96b4f4467D5BF4F33b6807803E3579
           └─ amount uint256 = 25000000  · 25 USDC ($24.99)

A Multicall3 batch, opened up to reveal a USDC transfer — with the token resolved on-chain and priced in dollars. Input is forgiving: the 0x prefix is optional, and whitespace, newlines, quotes, and stray JSON commas are all stripped before decoding.

TUI keys

key action
tab cycle focus: calldata input → target input → tree
↑↓ / jk move selection
enter / space collapse / expand the selected branch
← / → collapse (or jump to parent) / expand
e / c expand all / collapse all
pgup/pgdn, home/end, g/G paging and jumps
mouse wheel / click scroll the tree, select a row
i back to the calldata input
q / esc quit

The target field takes 0xaddress:chainId (or just :chainId) and tells the decoder which contract the calldata is sent to, which is what enables token symbols, decimals, and USD values for the top-level call. Nested calls work their targets out on their own from the surrounding data — a Safe's to, a multicall's target, a UserOp's sender.

One-shot flags

flag effect
--json print the decode tree as JSON
--full never truncate long hex values
--no-color plain output
--no-enrich skip token metadata + USD lookups
--address 0x… --chain 1 target contract: verified ABI (Sourcify/Etherscan) + token context

How it works

For every call in the tree — top-level and nested — candidate signatures are tried in order and the best match wins, where "best" strongly favours candidates whose decoded arguments re-encode to exactly the input bytes:

  1. Verified ABI from Sourcify (and Etherscan, if ETHERSCAN_API_KEY is set) when you pass --address
  2. Bundled signatures for the contracts you actually run into: ERC-20/721/1155/4626, WETH, Uniswap V2/V3/V4/Universal Router, Permit2, Safe, ERC-4337 EntryPoints v0.6–v0.8, smart accounts, Multicall3, 1inch, 0x, Sushi, Odos, and friends — these carry parameter names, so the output reads well
  3. openchain.xyz (spam-filtered)
  4. 4byte.directory (oldest entry first — newer duplicates are usually squatters)
  5. Shape guessing — when nothing matches, parameter types are inferred from the raw ABI encoding itself
  6. Raw word dump as the honest last resort

Any bytes value that looks like calldata gets the same treatment recursively, with guards against signature-database spam (non-word-aligned payloads only decode against curated signatures). Data a signature didn't consume — wallet attribution suffixes, appended junk — is surfaced as a (trailing data) node rather than silently dropped.

Decoding has been stress-tested against live traffic: ERC-4337 bundles, Safe batches, MetaMask delegation redemptions, CoW settlements, 114KB Seaport bulk orders, and every EVM swap source Relay.link routes through (Uniswap, PancakeSwap, KyberSwap, OpenOcean, Odos, 0x, Sushi…).

Enrichment

After decoding, the tree is annotated best-effort:

  • Token metadata — symbol/decimals/name read on-chain over public RPCs, for call targets and anything named like a token (tokenIn, srcToken, asset…), including both ends of packed swap paths
  • USD values — spot prices from the open Relay.link API, applied wherever an amount can be paired with a token, including native-value transfers and WRAP_ETH amounts
  • Anything that fails — dead RPC, unlisted token, unknown chain — just stays un-annotated

Special handling

  • ERC-4337 — packed v0.7 fields (initCode, paymasterAndData, accountGasLimits, gasFees) are split into their real components; UserOp callData descends through the account's execute/executeBatch
  • Safe — execTransaction payloads and packed multiSend batches
  • Uniswap Universal Router — command bytes become a named plan (V3_SWAP_EXACT_IN, PERMIT2_PERMIT, WRAP_ETH…), V4 action bytes are named, and packed V3 paths render as WETH –0.05%→ USDC
  • Value hints — checksummed addresses with well-known labels, max uint256, unix-timestamp deadlines

Development

bun run lint           # eslint — typescript-eslint + jsdoc required on every function
bun run typecheck      # tsc --noEmit
bun run test           # vitest, fully offline (the network is mocked)
bun run test:coverage  # coverage report; 100% thresholds are enforced

Unit coverage is held at 100% lines / statements / branches / functions for the decoder library and tree model. The interactive view and process entry are covered by a ConPTY end-to-end test instead (scripts/drive-tui.py), which drives the real TUI: paste → decode → navigate → collapse → quit.

The scripts/ directory also has the stress-testing tools: relay-hunt.ts and wild-hunt.ts pull complex live calldata (from Relay's API and from raw blocks), partner-hunt.ts targets Relay's swap-source routers specifically, and relay-eval.ts <manifest> [--verbose] decodes a sample set and reports how much of each tree resolved.

CI runs lint + typecheck + coverage on every push (ci.yml), the ConPTY e2e on Windows runners (e2e.yml), and publishing a GitHub release builds standalone binaries for Linux/macOS/Windows (release.yml).

Requirements

Bun ≥ 1.3 to run; Node.js ≥ 24 for the dev toolchain (vitest/eslint).

About

UserOperation calldata decoder

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages