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:
- Builds the shared
ExtensionConfig(loads chains + API keys). - Builds the shared
DetectedAbiStore(project-scoped pool of passively-discovered ABIs). - Registers a single
Web3EditorTabProvideras both an HTTP request editor provider and an HTTP response editor provider, threading the detected store down. - Registers the
Web3HistoryAnnotatorHttpHandlerfor history-row annotation. - Registers the
AbiDetectionScanCheckas a passive scan check (ScanCheckType.PER_REQUEST). - Registers the
Web3suite tab backed byMainChainsPanel.
Presentation layer
| Class | Role |
|---|---|
Web3EditorTabProvider | Factory; mints a fresh Web3RequestEditor / Web3ResponseEditor per Burp editor context. |
Web3RequestEditor | The Web3 Request tab. Renders decoded calldata as JSON and re-encodes edited args back into the request body on getRequest(). |
Web3ResponseEditor | The Web3 Response tab (read-only). Decodes the JSON-RPC result against the function/ABI recovered from the request. |
MainChainsPanel | The Web3 suite tab. A 2×2 dashboard wiring together chain management, ABI cache, ABI editor, and the standalone calldata codec. |
ui.editor.Web3JsonEditor | Custom 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:
collectRpcItemsnormalizes a body into a list of RPC items, transparently handling a single JSON-RPC object or a batch array.requestKey(request)= hash ofurl + body— a stable key shared by the request and response editors for the same message.- An
itemKeyofid:<jsonrpc-id>(preferred) orindex:<n>identifies each entry within a batch. decodeAndStoreresolves the chain, builds the decoder stack, decodes everyeth_call, recurses into multicalls, and writes each call's(functionSignature, abiJson)intoRequestContextStore.
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
| Class | Role |
|---|---|
Web3DataDecoder | Per-call strategy: resolve a proxy, pick an ABI source, decode input/output, or fall back to 4byte. Tracks decodeSource and the last ABI used. |
ABIInputDecoder | The 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(). |
RawTransactionDecoder | Decodes 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. |
MulticallRecursiveDecoder | Recognizes the six Multicall3 aggregate variants, extracts each inner (target, callData), and recursively decodes nested calls with a depth limit. |
Resolution layer
AbiProvideris a composite that queries sub-providers in priority order (cache → builtin → explorer) and recordslastAbiSource. Logs the "no explorer configured" notice once per chain so chains without an explorer don't spam.ProxyDetectoris a composite of slot-probe detectors (ERC1967,ZeppelinOS) with a shared cache.DetectedAbiStoreholds 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 viaextensionData(). Chain-agnostic — the decoder consults it after the chain-scoped chain and before 4byte.FourByteSignatureProvideris the last resort: it looks a selector up againstapi.4byte.sourcify.devand synthesizes a minimal ABI from the candidate signature(s).
Detection layer
AbiDetectionScanCheckimplements Burp'sPassiveScanCheck. For every audited response it runsAbiDetectionPipeline.detect(body), callsDetectedAbiStore.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.AbiDetectionPipelineis a stateless facade composing four single-purpose units:AbiTokenScanner— needle search forstateMutability:"/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-wideinStringbitmap once per response and shares it across all hits.AbiNormalizer— Jackson lenient parse with a preprocess pass that replaces minified!0/!1with JSONtrue/false; re-serialises with sorted keys so two logically-equal ABIs hash identically.AbiValidator— type-vocabulary check, SHA-256 content fingerprint, function selectors viaAbiSelectorBuilder.
Persistence layer
ExtensionConfigmerges the bundledchains.jsonwith chains/API keys stored in BurpPreferences, migrates deprecated chain IDs, and resolves the effective explorer API key (per-chain override, else a shared Etherscan-family key).CachedAbisstores downloaded/added ABIs in Burp'sPersistedObjectextension data, keyedchainId_address.DetectedAbiStorestores passively-detected ABIs in the sameextensionData()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 indone(). - The
ui.editorhighlighting 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.implementationCache—rpcUrl|address→ implementation (0x0sentinel for "not a proxy").EtherscanAbiProvider.chainIdSupportCache— chainId → Etherscan v2 support.RequestContextStore.requestContexts— request/response decode bridge.
- Persistent caches (survive Burp restarts):
CachedAbisandDetectedAbiStore(both project-scoped viaPersistedObject) andExtensionConfigchains/keys (user-scoped via Preferences).
Extension points
- New ABI source: implement
IAbiProviderand slot it intoAbiProvider's chain. - New proxy pattern: implement
IProxyDetectorandregisterDetector(...)it inProxyDetector. - 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
AbiBoundaryExtractorfor that shape, feeding its output intoAbiNormalizer.