Architecture

May 29, 2026 · View on GitHub

Web3 Decoder is a Burp Suite extension built on the Montoya API. Its job is to make the opaque calldata of Ethereum-style JSON-RPC traffic readable (and editable) inside Burp: it turns hex eth_call payloads into named functions and typed arguments, decodes the matching responses, and re-encodes edited arguments back into valid calldata.

This document describes how the pieces fit together. For the file/package layout see PROJECT_STRUCTURE.md; for libraries and build see TECH_STACK.md; for the user-facing capabilities see FEATURES.md.

Design goals

  • Decode in place. Reuse Burp's request/response editor tabs so analysts see decoded calldata next to the raw traffic, with no copy-paste.
  • Single-send sufficiency. A response can be decoded after one send, even if the analyst never opened the request tab first — the response side will decode the request on demand to recover the function/ABI identity it needs.
  • Best-effort ABI resolution. Try the cheapest, most authoritative source first (local cache), fall back through bundled ABIs, block explorers, a project-scoped pool of ABIs passively detected in dapp JS bundles, and finally a 4byte selector lookup that synthesizes an ABI.
  • Keep the EDT responsive. All network I/O (chain id lookup, ABI fetch, proxy slot reads) runs off the Swing Event Dispatch Thread via SwingWorker.

Layered view

graph TD
    subgraph Burp["Burp Suite host (Montoya API)"]
        EXT["Web3DecoderExtension<br/>(BurpExtension entry point)"]
    end

    subgraph UI["Presentation layer"]
        PROV["Web3EditorTabProvider<br/>(editor factory)"]
        REQ["Web3RequestEditor<br/>(Web3 Request tab)"]
        RESP["Web3ResponseEditor<br/>(Web3 Response tab)"]
        SUITE["MainChainsPanel<br/>(Web3 suite tab)"]
    end

    subgraph ORCH["Orchestration layer"]
        CTX["Web3RequestContextDecoder<br/>(headless decode + store)"]
        STORE["RequestContextStore<br/>(request to response bridge)"]
    end

    subgraph CORE["Decoding core"]
        DATA["Web3DataDecoder<br/>(per-call strategy)"]
        ABIDEC["ABIInputDecoder<br/>(web3j encode/decode engine)"]
        MULTI["MulticallRecursiveDecoder"]
    end

    subgraph RESOLVE["Resolution layer"]
        ABIPROV["AbiProvider<br/>(priority chain)"]
        PROXY["ProxyDetector<br/>(slot probes)"]
        DETSTORE["DetectedAbiStore<br/>(passive-detection pool,<br/>selector index)"]
        FOURBYTE["FourByteSignatureProvider"]
    end

    subgraph DETECT["Detection layer"]
        SCAN["AbiDetectionScanCheck<br/>(PassiveScanCheck)"]
        PIPE["AbiDetectionPipeline<br/>(token / boundary / normalize / validate)"]
    end

    subgraph PERSIST["Persistence layer"]
        CONFIG["ExtensionConfig<br/>(chains + API keys)"]
        CACHE["CachedAbis<br/>(PersistedObject)"]
    end

    subgraph EXTSVC["External services"]
        NODE["JSON-RPC node<br/>(eth_chainId, eth_getStorageAt)"]
        EXPL["Block explorer<br/>(Etherscan v2 / legacy)"]
        FB["api.4byte.sourcify.dev"]
    end

    EXT --> PROV
    EXT --> SUITE
    PROV --> REQ
    PROV --> RESP
    REQ --> CTX
    RESP --> CTX
    REQ --> STORE
    RESP --> STORE
    CTX --> STORE
    CTX --> DATA
    DATA --> ABIDEC
    CTX --> MULTI
    RESP --> MULTI
    DATA --> ABIPROV
    DATA --> PROXY
    DATA --> DETSTORE
    DATA --> FOURBYTE
    ABIPROV --> CACHE
    ABIPROV --> EXPL
    PROXY --> NODE
    FOURBYTE --> FB
    CTX --> NODE
    EXT --> SCAN
    SCAN --> PIPE
    SCAN --> DETSTORE
    SUITE --> CONFIG
    SUITE --> CACHE
    SUITE --> DETSTORE
    SUITE --> DATA
    CONFIG --> CACHE

Components

Entry point — Web3DecoderExtension

Implements BurpExtension. On initialize(MontoyaApi) it:

  1. Builds the shared ExtensionConfig (loads chains + API keys).
  2. Builds the shared DetectedAbiStore (project-scoped pool of passively-discovered ABIs).
  3. Registers a single Web3EditorTabProvider as both an HTTP request editor provider and an HTTP response editor provider, threading the detected store down.
  4. Registers the Web3HistoryAnnotator HttpHandler for history-row annotation.
  5. Registers the AbiDetectionScanCheck as a passive scan check (ScanCheckType.PER_REQUEST).
  6. Registers the Web3 suite tab backed by MainChainsPanel.

Presentation layer

ClassRole
Web3EditorTabProviderFactory; mints a fresh Web3RequestEditor / Web3ResponseEditor per Burp editor context.
Web3RequestEditorThe Web3 Request tab. Renders decoded calldata as JSON and re-encodes edited args back into the request body on getRequest().
Web3ResponseEditorThe Web3 Response tab (read-only). Decodes the JSON-RPC result against the function/ABI recovered from the request.
MainChainsPanelThe Web3 suite tab. A 2×2 dashboard wiring together chain management, ABI cache, ABI editor, and the standalone calldata codec.
ui.editor.Web3JsonEditorCustom Swing JSON editor (syntax + semantic highlighting, line gutter, search bar, live validity) used by both HTTP message tabs and the Calldata Codec. Pure UI — the only async is an EDT re-style debounce timer; no SwingWorker, no network.

Orchestration layer

Web3RequestContextDecoder is the headless heart of decoding. Both editors funnel through it so the logic lives in one place:

  • collectRpcItems normalizes a body into a list of RPC items, transparently handling a single JSON-RPC object or a batch array.
  • requestKey(request) = hash of url + body — a stable key shared by the request and response editors for the same message.
  • An itemKey of id:<jsonrpc-id> (preferred) or index:<n> identifies each entry within a batch.
  • decodeAndStore resolves the chain, builds the decoder stack, decodes every eth_call, recurses into multicalls, and writes each call's (functionSignature, abiJson) into RequestContextStore.

For eth_sendRawTransaction, decodeAndStore first RLP-decodes the signed transaction (RawTransactionDecoder) into a transaction envelope, then decodes the inner (to, data) through the same per-call path as eth_call. The chain id embedded in the transaction is preferred over the eth_chainId probe; decoders are built lazily and memoized per chain id so a batch mixing methods/chains is handled in one pass.

RequestContextStore is a process-wide ConcurrentHashMap keyed by requestKey → (itemKey → ContextEntry). It is the bridge that lets the response editor learn which function/ABI a response's bytes belong to — that identity only exists in the request, never the response.

Decoding core

ClassRole
Web3DataDecoderPer-call strategy: resolve a proxy, pick an ABI source, decode input/output, or fall back to 4byte. Tracks decodeSource and the last ABI used.
ABIInputDecoderThe low-level engine over web3j. Builds function selectors, maps Solidity types to web3j types, and decodes/encodes inputs and outputs — including nested static/dynamic tuples and arrays. Also exposes a CLI main().
RawTransactionDecoderDecodes a signed eth_sendRawTransaction hex into an envelope (type, chain id, recovered sender, to, nonce, value, gas) and exposes its inner calldata. Pure/offline (web3j TransactionDecoder + EC recovery); no ABI or network.
MulticallRecursiveDecoderRecognizes the six Multicall3 aggregate variants, extracts each inner (target, callData), and recursively decodes nested calls with a depth limit.

Resolution layer

  • AbiProvider is a composite that queries sub-providers in priority order (cache → builtin → explorer) and records lastAbiSource. Logs the "no explorer configured" notice once per chain so chains without an explorer don't spam.
  • ProxyDetector is a composite of slot-probe detectors (ERC1967, ZeppelinOS) with a shared cache.
  • DetectedAbiStore holds ABIs the passive scanner has extracted from response bodies. Keyed by SHA-256 fingerprint, indexed by 4-byte selector for O(1) decode lookup. Persists across Burp restarts via extensionData(). Chain-agnostic — the decoder consults it after the chain-scoped chain and before 4byte.
  • FourByteSignatureProvider is the last resort: it looks a selector up against api.4byte.sourcify.dev and synthesizes a minimal ABI from the candidate signature(s).

Detection layer

  • AbiDetectionScanCheck implements Burp's PassiveScanCheck. For every audited response it runs AbiDetectionPipeline.detect(body), calls DetectedAbiStore.saveIfNew(...) on each result, and raises one informational audit issue per response that introduces a new ABI. The issue detail lists every function/event/error with its canonical signature and 4-byte selector.
  • AbiDetectionPipeline is a stateless facade composing four single-purpose units:
    • AbiTokenScanner — needle search for stateMutability:" / internalType:" (both unquoted and quoted forms) returns candidate byte offsets.
    • AbiBoundaryExtractor — string-aware, depth-tracking walk left from each hit to the outermost enclosing [{...}] array. Pre-computes a body-wide inString bitmap once per response and shares it across all hits.
    • AbiNormalizer — Jackson lenient parse with a preprocess pass that replaces minified !0/!1 with JSON true/false; re-serialises with sorted keys so two logically-equal ABIs hash identically.
    • AbiValidator — type-vocabulary check, SHA-256 content fingerprint, function selectors via AbiSelectorBuilder.

Persistence layer

  • ExtensionConfig merges the bundled chains.json with chains/API keys stored in Burp Preferences, migrates deprecated chain IDs, and resolves the effective explorer API key (per-chain override, else a shared Etherscan-family key).
  • CachedAbis stores downloaded/added ABIs in Burp's PersistedObject extension data, keyed chainId_address.
  • DetectedAbiStore stores passively-detected ABIs in the same extensionData() namespace, keyed by content fingerprint. FIFO-bounded (default 1024 entries) with oldest-first eviction; in-memory selector index rebuilt at startup.

Key flows

1. Request decode (Web3 Request tab)

sequenceDiagram
    autonumber
    participant U as Analyst
    participant RE as Web3RequestEditor
    participant SW as SwingWorker (off-EDT)
    participant CTX as Web3RequestContextDecoder
    participant NODE as JSON-RPC node
    participant AP as AbiProvider
    participant DD as Web3DataDecoder
    participant MD as MulticallRecursiveDecoder
    participant ST as RequestContextStore

    U->>RE: open request in Web3 Request tab
    RE->>RE: clearRequestContext(requestKey)
    RE->>SW: decodeBatchAsync
    SW->>CTX: decodeAndStore(reqResp, requestKey)
    CTX->>NODE: eth_chainId (cached per URL)
    NODE-->>CTX: chainId
    CTX->>AP: build provider for chain
    loop each eth_call item
        CTX->>DD: decodeInput(to, data)
        DD->>AP: getAbi(address)
        AP-->>DD: abiJson (+ source)
        DD-->>CTX: {function, args, proxy?, decodeSource}
        CTX->>ST: storeContext(requestKey, itemKey, fn, abi)
        CTX->>MD: decodeNestedCalls(...) if multicall
        MD->>DD: decode each inner (target, callData)
        MD->>ST: record nested context at path
    end
    CTX-->>SW: rows
    SW-->>RE: render decoded JSON
    RE-->>U: function + typed args

On edit + send, Web3RequestEditor.getRequest() reads the edited JSON, looks up the stored ContextEntry for each item, calls ABIInputDecoder.encodeInput(...), and writes the new data back into the original request body (single or batch).

2. Response decode (Web3 Response tab)

The response only carries raw result bytes — decoding them requires the request's function/ABI. If the request tab was never opened, the response editor decodes the request itself first.

sequenceDiagram
    autonumber
    participant RESP as Web3ResponseEditor
    participant SW as SwingWorker (off-EDT)
    participant ST as RequestContextStore
    participant CTX as Web3RequestContextDecoder
    participant DEC as ABIInputDecoder
    participant MD as MulticallRecursiveDecoder

    RESP->>SW: decodeResponseAsync(requestKey)
    SW->>ST: hasAnyContext(requestKey)?
    alt context missing (single-send case)
        SW->>CTX: decodeAndStore(reqResp, requestKey)
        Note over CTX: decodes the request to populate context
    end
    SW->>ST: resolve ContextEntry by id then index then legacy
    ST-->>SW: {functionSignature, abiJson}
    SW->>DEC: decodeOutput(fn, abiJson, resultHex)
    DEC-->>SW: {function, output}
    SW->>MD: enrichMulticallReturnData(...) if multicall
    Note over MD: decode each inner returnData using<br/>per-call context stored during request decode
    SW-->>RESP: render decoded JSON

3. ABI resolution priority

Web3DataDecoder walks the resolution chain cheapest/most-authoritative first. Chain-scoped sources (cache, builtin, explorer) are skipped when the chain id can't be determined; the chain-agnostic sources (detected pool, 4byte) still run.

flowchart TD
    START["decodeInput(to, data)"] --> HAS_CHAIN{"chain id resolved<br/>and AbiProvider built?"}
    HAS_CHAIN -->|yes| CACHE{"Cached ABI?<br/>(CachedAbiProvider)"}
    HAS_CHAIN -->|no| DET
    CACHE -->|hit| RET_CACHE["source = cache"]
    CACHE -->|miss| BUILTIN{"Bundled ABI?<br/>(BuiltinAbiProvider, e.g. Multicall3)"}
    BUILTIN -->|hit| RET_BUILTIN["source = builtin"]
    BUILTIN -->|miss| HASEXPL{"Explorer configured<br/>for chain?"}
    HASEXPL -->|no| DET
    HASEXPL -->|yes| V2{"Etherscan v2 supports<br/>this chainId?"}
    V2 -->|yes| V2CALL["GET /v2/api?chainid=..."]
    V2 -->|no| LEGACY["GET legacy /api on<br/>configured explorer host"]
    V2CALL -->|found| SAVE["cache ABI + source = etherscan"]
    V2CALL -->|unsupported chainid| LEGACY
    LEGACY -->|found| SAVE
    V2CALL -->|not found| DET
    LEGACY -->|not found| DET
    DET{"Detected pool has<br/>matching selector?"}
    DET -->|yes| RET_DET["source = detected"]
    DET -->|no| FB{"FourByteSignatureProvider<br/>available?"}
    FB -->|yes| FB_LOOKUP["lookup selector on 4byte,<br/>synthesize ABI, source = 4byte"]
    FB -->|no| FAIL["throw: ABI not found"]

3b. Passive ABI detection flow

Independent of the decode path, every HTTP response Burp passive-audits is offered to the detection pipeline. New ABIs land in the same store the decoder reads from.

sequenceDiagram
    autonumber
    participant BURP as Burp Scanner
    participant SC as AbiDetectionScanCheck
    participant PIPE as AbiDetectionPipeline
    participant STORE as DetectedAbiStore
    participant ISS as Audit Issue

    BURP->>SC: doCheck(httpRequestResponse)
    SC->>PIPE: detect(body)
    Note over PIPE: tokens → boundaries → normalize → validate<br/>(string-aware, !0/!1 preprocessing,<br/>SHA-256 fingerprint)
    PIPE-->>SC: List<DetectedAbi>
    loop each ABI
        SC->>STORE: saveIfNew(abi, sourceUrl)
        alt newly added
            STORE-->>SC: true
        else already known
            STORE-->>SC: false
        end
    end
    alt any newly added
        SC->>ISS: raise INFORMATION issue<br/>(detail lists every method + selector)
    end

4. Proxy-aware decoding

When an RPC URL is available, Web3DataDecoder asks ProxyDetector whether the target is a delegating proxy and, if so, decodes against the implementation's ABI.

flowchart TD
    A["getImplementationAddress(addr)"] --> CK{"cached?"}
    CK -->|yes| RC["return cached (or null for 0x0)"]
    CK -->|no| D1["ERC1967ProxyDetector:<br/>eth_getStorageAt slot 0x360894...bbc"]
    D1 -->|non-zero| IMPL["implementation = last 20 bytes"]
    D1 -->|zero/none| D2["ZeppelinOSProxyDetector:<br/>eth_getStorageAt slot 0x7050c9...8c3"]
    D2 -->|non-zero| IMPL
    D2 -->|zero/none| NONE["cache 0x0, return null (not a proxy)"]
    IMPL --> USE["decode against implementation ABI<br/>(falls back to proxy-address ABI if missing)"]

Threading & caching model

  • EDT discipline. Editors capture state on the EDT, then do all decode/network work inside a SwingWorker.doInBackground() and push results back in done().
  • The ui.editor highlighting pass runs on the EDT, debounced ~200 ms; it performs no I/O.
  • Process-wide caches (all static, shared across editor instances):
    • Web3RequestContextDecoder.chainIdCache — URL → chainId.
    • ProxyDetector.implementationCacherpcUrl|address → implementation (0x0 sentinel for "not a proxy").
    • EtherscanAbiProvider.chainIdSupportCache — chainId → Etherscan v2 support.
    • RequestContextStore.requestContexts — request/response decode bridge.
  • Persistent caches (survive Burp restarts): CachedAbis and DetectedAbiStore (both project-scoped via PersistedObject) and ExtensionConfig chains/keys (user-scoped via Preferences).

Extension points

  • New ABI source: implement IAbiProvider and slot it into AbiProvider's chain.
  • New proxy pattern: implement IProxyDetector and registerDetector(...) it in ProxyDetector.
  • New multicall variant: add its canonical signature + descriptor extraction to MulticallRecursiveDecoder.
  • New chain: add it to chains.json (or via the Web3 tab at runtime).
  • New ABI-detection shape (e.g. Truffle/Hardhat artifact wrappers, ethers.js human-readable string fragments): add a new pre-stage or replace AbiBoundaryExtractor for that shape, feeding its output into AbiNormalizer.