Skip to content

feat: address-aware ABI decoding for clauses, events, and reverts#594

Merged
daithihearn merged 24 commits into
mainfrom
feat/decoding-pipeline
Jun 9, 2026
Merged

feat: address-aware ABI decoding for clauses, events, and reverts#594
daithihearn merged 24 commits into
mainfrom
feat/decoding-pipeline

Conversation

@Agilulfo1820

@Agilulfo1820 Agilulfo1820 commented May 30, 2026

Copy link
Copy Markdown
Member

Summary

Ports the decoding pipeline from inspector-app#204 into the explorer. Today's useDecodeInputData / useDecodeEvent only ask b32 for a 4-byte/topic0 match — when b32 doesn't have the signature (most non-trivial VeBetterDAO clauses, every Uniswap/Aave fork, every custom error, every Panic) the user sees raw hex. This PR adds three more sources behind the existing ones and threads the clause / emitter address through the decoder so we can use the most specific ABI available.

Decoding pipeline (high → low confidence)

  1. Known contracts — bundled ABIs for the 7 VeChainThor protocol built-ins (Authority, VTHO, Executor, Extension, Params, Prototype, Staker) plus a curated address → name map for ~30 VeBetterDAO / Stargate / Smart Account / oracle / legacy-nodes contracts on mainnet + testnet. Synchronous, no network calls.
  2. Sourcify — new server-side /api/sourcify proxies sourcify.dev/server. The client-side resolver reads the EIP-1967 implementation slot via ThorClient.accounts.getStorageAt and prefers the impl ABI when present, falling back to the address itself. This is what handles all OZ TransparentUpgradeable / UUPS proxies (i.e. essentially every VeBetterDAO deployment).
  3. b32 — kept the existing /api/b32 hook; now skipped when an upstream source already decoded.
  4. OpenChain — new server-side /api/openchain proxies api.openchain.xyz. Synthesises ABI items from canonical signatures; for events it tries indexed-first then indexed-last layouts so OZ-convention and reversed-indexed contracts both decode.

Caches are session-scoped via TanStack Query with staleTime: Infinity — no IndexedDB layer.

Other UX changes

  • Inline contract names next to clause.to in TransactionClauses and next to the emitter address in EventList. Uses useContractName(address) which prefers built-in/curated name, then Sourcify's metadata.contractName.
  • Decoded tab is the default on InputData and EventList. The "No ABI found" placeholder in the decoded pane stands in when nothing resolves, so the UI feels consistently verified.
  • ERC-20 / ERC-721 badge on the contract address page, driven by a bytecode-substring sniff (lib/contract-sniff.ts).

Revert decoding

getTransactionFailureInsight no longer relies solely on the SDK's decodeRevertReason (which only handles Error(string)). New path:

  • 0x08c379a0 → string decode (as before).
  • 0x4e487b71Panic(uint256) with human-readable code descriptions (e.g. Panic(0x11): arithmetic overflow or underflow).
  • Anything else → custom-error decode against the reverted clause's resolved ABI (known + Sourcify), falling back to OpenChain for unknown selectors.

The result is typed with a revertKind discriminator so the UI can format payloads richly in follow-up work.

Adds a four-source decoding pipeline (known contracts → Sourcify with
EIP-1967 proxy follow → b32 → OpenChain) so input data, event logs, and
custom errors decode against the most specific ABI available rather than
giving up after b32 misses. Inline contract names render next to clause
targets and event emitters, and contract pages get an ERC-20 / ERC-721
badge based on bytecode sniffing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 30, 2026

Copy link
Copy Markdown

Block explorer – Preview

Name Status Preview Updated (UTC)
block-explorer-preview 🗑️ Destroyed (31ceed1) Preview deleted 2026-06-09 15:12:41

Preview environment destroyed after PR was closed

@Agilulfo1820 Agilulfo1820 added the increment:minor PR adds functionality in a backwards compatible manner label May 30, 2026
@github-actions

github-actions Bot commented May 30, 2026

Copy link
Copy Markdown

PR Docker Image Published

docker pull ghcr.io/vechain/block-explorer:pr.594.31ceed1
Run locally
docker run --rm -p 3000:3000 ghcr.io/vechain/block-explorer:pr.594.31ceed1

@Agilulfo1820
Agilulfo1820 marked this pull request as draft May 30, 2026 06:43
Two issues from the PR review:

- The revert reason alert overflowed horizontally when the decoder fell
  through to raw hex. Add `wordBreak: 'break-all'` and `minW: 0` on the
  content so long un-spaced hex wraps inside the alert box.
- Most VeBetterDAO / Stargate impls aren't verified on Sourcify (only
  the proxies are), so the Sourcify-with-EIP-1967-follow path returned
  the proxy's ABI and missed every error / function defined on the impl.
  Bundle the full set of b32-catalogued VeBetterDAO + Stargate ABIs so
  curated addresses resolve to the impl ABI synchronously, no Sourcify
  round-trip. Mirrors the inspector-app strategy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With React Query 5's skipToken, a skipped query stays in status='pending'
forever (fetchStatus='idle'). The hook was aggregating isPending from all
three sources (resolved-ABI, b32, OpenChain) — so as soon as the first
source resolved successfully and we skipped the downstream ones via
skipToken, the hook reported isPending: true permanently and the
InputData / EventList components rendered the loading Skeleton instead
of the decoded payload.

Switch the aggregate to isFetching, which only reflects in-flight
requests. Skipped branches no longer keep the hook pending, so successful
upstream decodes actually render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups from PR review:

- Custom-error reverts often surface from a contract called internally,
  not the clause's target. Inspector handles this by sweeping every
  imported ABI; do the same here by iterating all bundled ABIs after
  the target's resolved ABI misses. Adds getAllBundledAbis() exporter.
- The parameter zod schema required internalType, which is fine for
  b32 ABIs (always present) but fails for OpenChain-synthesised items
  that only carry name+type. zodParse swallowed the error and returned
  raw data anyway, but the console noise + schema mismatch was hiding
  real issues. Mark internalType optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-referenced against the VeBetterDAO frontend config. Gaps:

- Node Management (mainnet + testnet) — bundled new ABI from b32
- B3TR MultiSig (mainnet only) — bundled multi-sig-wallet ABI from b32
- VeBetter Passport, DBA Pool, Relayers Rewards Pool, X2Earn Creator NFT,
  Grants Manager — addresses on testnet were missing, so curated name
  lookup never hit. ABIs were already bundled, just needed wiring.

veDelegate, veDelegateAutoDeposit, VetDomains, and the per-library
governor/passport addresses are not on b32 and not bundled. The library
contracts get inlined into the governor/passport ABIs at compile time
anyway, so they don't need separate entries for decoding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recasts the transaction detail view around what the tx actually did and
gates the low-level plumbing behind an Expert toggle. Based on the
ui_kits/block-explorer design.

- Lean default 3-up Overview (Value transferred / Fee Paid / Status with
  confirmations badge) replaces the wall of technical details as the
  primary readout. Fee is the single accented figure.
- New Expert mode switch in the page header. When off:
  - Technical-details grid (Type / Confirmations / Size / Expiration /
    Nonce) and the Fees, Gas and VTHO breakdown are hidden.
  - Each clause's Raw/Decoded input-data toggle is hidden; the decoded
    pane (with its "No ABI found" placeholder) is the only view.
  - Per-event Raw/Decoded toggle is hidden; events render decoded only.
- Activity card header now reads "Activity" with pill tabs
  "Clauses (N)" / "Events (M)" so users can see the counts up front.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Agilulfo1820
Agilulfo1820 marked this pull request as ready for review June 8, 2026 13:08
env.api.ts threw on import when B32_URL wasn't set in process.env, and
since every API route pulls something out of this module, the throw
took down /api/b32, /api/sourcify and /api/openchain with a 500. That's
why decoding looked dead on the preview env and on production for any
clause that needed b32 or OpenChain (e.g. swapTokensForExactETH on
non-known router contracts) — the routes never even ran their handlers.

The Dockerfile already has ARG B32_URL=https://b32.vecha.in and ENV
B32_URL=${B32_URL}, but App Runner's runtime env substitution doesn't
preserve image ENV. Apply the same default at the env module so the
client survives a missing runtime variable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CopyableAddressLink renders a <button> for its copy icon, and the whole
clause header sat inside Accordion.ItemTrigger which is also a <button>.
React refused to hydrate that ("button cannot be a descendant of
button") and the row would re-render incorrectly.

Restructure the header as a Flex row with two Accordion.ItemTrigger
zones — one wrapping the index + type badge, the other wrapping the
VET balance + chevron — and the ClauseTarget (with its address +
copy button) sitting in the middle as a plain inline element. Clicking
the address opens the contract page or copies; clicking either trigger
zone toggles the panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chakra's Accordion.ItemTrigger applies width: 100% via its recipe, which
made the second trigger expand to fill the row and push everything past
the first trigger off-screen — including the address link and VET value.

Switch both triggers to render via Accordion.ItemTrigger asChild around
a chakra.button element with explicit width="auto" so they shrink to
their content. Also inline the resolved ContractName.method label
inside the first trigger (text-only, no button-in-button risk) and
move the CopyableAddressLink between the two triggers as a sibling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues raised on a reverted testnet B3TR Emissions.distribute() tx:

1. **No revert reason shown.** The fallback was a post-block simulation at
   the tx's own blockID, but reverts in time-gated / randomness /
   internal-out-of-gas paths can't be reproduced cleanly that way — the
   simulation succeeds even though the on-chain tx reverted. Inspector
   PR #204 sidestepped this by replaying the actual on-chain execution
   via /debug/tracers and walking the resulting call tree.

   Mirror that here:
   - Call `thorClient.debug.traceTransactionClause` on each clause with
     the 'call' tracer.
   - Walk to the deepest frame that carries an `error` string.
   - If the frame has an `output`, decode it as revert data (Error /
     Panic / custom error / OpenChain).
   - Otherwise surface the frame's `error` (`out of gas`, `invalid
     opcode`, …) as the vm-error revert reason.
   - Simulation stays as the secondary path for nodes without
     /debug/tracers enabled or when the tracer can't reach the clause.
   - As a final guard, return a generic "execution reverted" so the
     alert never silently disappears for a reverted tx.

2. **"Input data" header with empty body** on zero-arg functions like
   `distribute()` / `claim()`. The decoder succeeded but ParamRows
   returned null, leaving just the label. Emit an explicit "No
   parameters." placeholder so the section reads cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

* perf: consolidate signature decoding into a single cached service

Replaces the three per-source proxy routes (/api/sourcify, /api/b32,
/api/openchain) with a service-layer abstraction that caches at the
question level rather than the datasource level.

The previous shape leaked implementation through the API: callers had
to know about b32 vs OpenChain and orchestrate "try one, fall back to
the other" in two separate hooks. Caching was per-route, so concurrent
in-flight requests for the same selector still hit upstream N times
and a 429 / 5xx burst could starve our App Runner egress IP.

Two services, one cache pattern each:

- /api/sourcify keeps its name (single source, no abstraction needed)
  but now sits behind fetchSourcify in lib/sourcify-cache.ts.
- /api/decode/selector is new and replaces /api/b32 + /api/openchain.
  decodeSelector in lib/selector-decoder/ fans out to b32 and OpenChain
  in parallel, returning a discriminated result so the client knows
  whether it got a baked ABI fragment (b32) or a bare signature it has
  to interpret (OpenChain — needed because only the caller knows how
  many topics are indexed for events).

Each service owns two storage tiers:

- async-cache-dedupe holds successes (1d TTL, 7d SWR). Provides single-
  flight: N concurrent callers for the same key share one upstream
  promise, so cold-start stampedes collapse to one upstream call.
  Rejections are not stored, so 5xx and 429 never poison the cache —
  but in-flight dedup still applies during the rejection window.
- lru-cache holds 404 markers (5m TTL). Lets freshly-verified
  contracts and newly-curated selectors surface within minutes,
  independent of the long success TTL.

OpenChain batching is preserved via a module-scoped DataLoader per
kind. Concurrent in-flight selector requests across the whole pod
coalesce into one upstream call per ~5ms tick, so the upstream cost
stays bounded even though the client now hits the route one selector
at a time.

Client-side simplifications:

- services/b32.ts and services/openchain.ts collapse into one
  services/selector-decoder.ts with a single useDecodedSelector hook.
- useDecodeInputData and useDecodeEvent lose the explicit b32 →
  OpenChain fallback ladder (the service handles it server-side) and
  drop one "wait for b32 to settle before trying OpenChain" gate.
- The revert decoder in services/thor/transaction.ts uses the same
  unified service instead of going directly to OpenChain.

Known limitation unchanged from the previous caching attempt: the
in-memory caches are per-pod and lost on restart. Persistent cache
via async-cache-dedupe's Redis storage backend is straightforward to
add when rate-of-change justifies it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: migrate Sourcify proxy from v1 to v2

Sourcify is running scheduled brownouts of API v1 — multi-hour windows
where every /files/any/{chainId}/{address} request returns 503 with a
"please migrate to v2" body. They do this to force consumers off the
deprecated API ahead of permanent removal. Today's window
(10:00–18:00 UTC) was hitting every /api/sourcify call on local dev
with 502s upstream.

Two changes in lib/sourcify-cache.ts:

- URL: /files/any/{chainId}/{address}
  → /v2/contract/{chainId}/{address}?fields=abi,compilation

- Response shape: v1 returned a `files[]` array we had to walk looking
  for `metadata.json`, then parse its content as JSON to extract
  `output.abi` and `settings.compilationTarget`. v2 returns `abi` as a
  top-level field and `compilation.name` for the contract name, so the
  extractAbi helper goes away entirely.

404 handling is unchanged — v2 still uses HTTP 404 for unverified
contracts. The cache, dedup, and TTL machinery is untouched; only the
upstream call shape changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: normalize selector hash to lowercase before upstream lookup

decodeSelector lowercased the cache key but forwarded the original-case
hash to fetchB32 / fetchOpenchain. b32's URL path is case-sensitive and
OpenChain echoes lowercase keys in its response bucket, so an uppercase
request would miss upstream and poison the lowercased cache key with a
spurious not-found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@daithihearn
daithihearn merged commit ddf0611 into main Jun 9, 2026
15 checks passed
@daithihearn
daithihearn deleted the feat/decoding-pipeline branch June 9, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

increment:minor PR adds functionality in a backwards compatible manner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants