Skip to content

perf: cache the Sourcify / OpenChain / b32 proxy responses#598

Closed
Agilulfo1820 wants to merge 26 commits into
mainfrom
feat/decoding-pipeline-cache
Closed

perf: cache the Sourcify / OpenChain / b32 proxy responses#598
Agilulfo1820 wants to merge 26 commits into
mainfrom
feat/decoding-pipeline-cache

Conversation

@Agilulfo1820

Copy link
Copy Markdown
Member

Why

The decoding pipeline introduced in #594 has every /api/sourcify, /api/openchain, and /api/b32 call pass straight through to the upstream. TanStack Query's staleTime: Infinity on the client only helps within one browser session; the routes set no cache headers and don't wrap their upstream fetch() in next: { revalidate }. With ~14 req/s headroom on Sourcify's free tier, our single App Runner egress IP is one busy preview env away from being throttled in production.

This PR adds a thin caching layer to the three proxy routes. Stacked on top of #594 — merge target is feat/decoding-pipeline, not main.

What

For each route:

  1. Wrap the upstream fetch in next: { revalidate, tags } so repeats inside the TTL are served from Next's data cache (.next/cache/fetch-cache under output: 'standalone'). No external CDN required. Tagged so a future maintenance route can revalidateTag() without redeploying.
  2. Set Cache-Control on the JSON response so browsers and any future CDN cache too.

TTLs differ by upstream stability:

Route next: { revalidate } (200) Response 200 Cache-Control 404 Cache-Control
/api/sourcify 7d s-maxage=604800, stale-while-revalidate=2592000, max-age=86400 s-maxage=1800, stale-while-revalidate=86400, max-age=300
/api/openchain 7d same as Sourcify when every hash hit; partial / miss uses the short 30m / 1d SWR window same
/api/b32 1d s-maxage=86400, stale-while-revalidate=604800, max-age=3600 same

Rationale for the split:

  • Sourcify — a contract's ABI is tied to a one-shot verification at an address. Once verified, the ABI is effectively immutable. Long TTL is safe.
  • OpenChainhash → canonical signature is a cryptographic fact; the DB only grows. 200s can be cached for a week. The route now switches between "all hit" and "partial" Cache-Control depending on whether every requested hash resolved, so a newly indexed selector / topic surfaces inside 30 min instead of being stuck as a "no match" for a week.
  • b32 — community-curated GitHub repo where entries get added and occasionally corrected. 1-day TTL keeps corrections from being stuck for a week.

5xx / timeout / network errors return Cache-Control: no-store so an upstream blip can't poison the cache.

/api/b32 also switches from apiClient.get (which doesn't forward next options) to a direct fetch, matching the pattern in the other two routes.

Known limitation — deploy / restart

Next's data cache lives in the container filesystem under .next/cache/fetch-cache and is wiped on every deploy and instance restart / scale-up. With 1–10 App Runner pods (per prod.yaml), the worst-case cold burst is pods × unique-contracts hit in the warming minute. Sourcify's ~14 req/s should absorb this; the durable fix is a persistent custom cacheHandler (Redis / Memcached / S3) — flagged as future work.

Test plan

  • pnpm ts-check && pnpm lint && pnpm knip && pnpm test (223 pass)
  • Local smoke (pnpm dev): all three routes return the expected Cache-Control header (verified via curl -sI against 0x5ef79995…, 0x095ea7b3, 0x4a25d94a).
  • PR preview smoke: hit /api/sourcify?chainId=100009&address=0x5ef79995… twice via curl, confirm the second response is markedly faster and carries the 7d header.
  • Browser smoke on the preview env: open the same VeBetterDAO tx twice; second visit should serve /api/sourcify from disk cache / 304, no upstream Sourcify fire visible in dev-server log.
  • Force a 404 path (random unverified address); verify the response carries the short 30-min s-maxage.

🤖 Generated with Claude Code

Agilulfo1820 and others added 24 commits May 29, 2026 18:56
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>
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>
Three things were off vs the kit's clause spec:

- The Call / Transfer type badge in the clause header was always shown;
  in non-expert it's just noise. Gate it on expert.
- The function signature line (e.g. castVoteOnBehalfOf(address,uint256))
  rendered above the param table in every view; same rationale — hide
  it in non-expert.
- The clause-header address was only truncated on mobile. The design
  uses the short 0xABCD…WXYZ form on desktop too. Always truncate.

Also tightened the decoded param-table column widths so they match the
design's proportions (narrow #, fixed-width name/type, data takes the
rest) instead of evenly distributing four 1fr columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously gated on expert mode after the earlier hide-by-default pass.
The method name is the single most useful identifier of what a clause
does — keep it visible regardless of expert state. The Call/Transfer
type badge stays expert-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applies to both clause Input data and event params. The type is the
single most technical column and adds noise for the lean default view;
it pops back in when expert is on (alongside the indexed badge for
events). Grid template collapses to # / Name / Data so the data
column gets the reclaimed width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CopyableAddressLink sits inside the Accordion.ItemTrigger, so opening
the contract page or copying the address was also toggling the panel.
Wrap just the link/copy controls with stopPropagation so the rest of
the header still toggles the accordion as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wrapping border + alternating-row borders on the decoded param table
made the inner block feel boxed-in inside the already-bordered accordion
item. Strip the outer border, drop the header background, and use a
hairline rgba(255,255,255,.08) divider between rows — the same whisper
the inspector design uses.

Also bump the typography one step (bodyS -> bodyM for index/name,
bodyXs -> bodyS for type and value), widen the # / Name / Type columns
slightly so labels breathe, and switch the Data column from
text-secondary to text-primary so the actual value reads as the
important content. Pad row gaps from py=3 to py=4 for more breathing
room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loading event rows used to collapse to a flat 120px gray block, which
made the activity card jump around as decodes streamed in. Replace it
with an EventCardSkeleton that keeps the real card chrome (border, bg,
internal gaps), two stacked lines for the name + emitter, and two
faux param rows on the same 52px / 180px / data grid as the live
table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
useContractName now consults vechain/token-registry between the
bundled-known step and Sourcify, prefering the registry's symbol
(B3TR, USDT, ...) over the full name. This picks up the long tail of
ERC-20s and ERC-721s that aren't in our curated VeBetterDAO/Stargate
set, so clause targets and event emitters render with a recognisable
short name when the address matches a registered token.

Priority is now: bundled known → token registry → Sourcify
metadata.contractName. The Sourcify call only fires when the first two
miss, which means most tx pages skip the network round-trip entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crawled the top-500 toplists on vechainstats.com/contracts/ across the
clauses-received, vtho-burned, co2e and new tabs (so 2000 entries with
overlap), kept the rows that carry a curated display name, deduped
against our existing built-in + curated registry, and stripped
trailing "(...)" suffixes to keep the names compact.

Result: 436 new mainnet addresses with human names — DEXes (Vexchange,
VeRocket), X2Earn apps (Mugshot, Greencart, Cleanify, Toolchain *),
oracles, certificate-issuance contracts, etc.

Wired into getKnownContract as a third lookup tier (after BUILTIN and
CURATED_ADDRESS_TO_NAME): mainnet-only, name without ABI. Sourcify
still owns ABI resolution downstream, so the new entries cost nothing
beyond the JSON they load from. Testnet is unaffected (vechainstats
doesn't ship a testnet equivalent).

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

The vechainstats crawl pulled in 130 entries under the *.vedelegate.vet
sub-domain (mostly per-delegator auto-generated wallets like
"12553503427381...vedelegate.vet" plus the three top-level
veDelegate / veDelegate Trigger / veDelegate Auto Deposit entries).
Those clutter clause headers with names that don't help interpretation,
so strip the lot from the bundle. Drops the registry from 436 to 306.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the source-of-data reference from the filename. The file is just
an address → display-name map; provenance lives in commit history.
Variable + import renamed to match (ADDRESS_NAMES / addressNamesRaw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Method name now inlines as ContractName.method right after the
  contract label in the clause header, so the call's intent is read in
  one go without scanning past the address.
- Drop the success/pending TxStatusBadge from the page header — it
  added noise on success and the Overview's confirmations badge is
  the meaningful indicator. Reverts still surface via the revert
  alert. TxStatusBadge is now unused so its definition is removed
  (TxStatusIcon stays, the recent-tx tables use it).
- Switch the decoded param table to mono fontSize='xs' across
  index / name / type / value, matching the inspector spec.
- Lose the outer border on the 'No ABI found' placeholder so it
  doesn't double-box inside the already-bordered clause/event card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Every /api/sourcify, /api/openchain, /api/b32 hit was passing straight
through to the upstream — TanStack Query's staleTime: Infinity only
helps within one browser session, and the routes set no cache headers
or `next: { revalidate }`, so distinct (chainId, address) /
(kind, hash) / (signature) tuples paid the full upstream cost every
time. Sourcify's free tier is ~14 req/s per IP and our App Runner
egress IP is what gets throttled — one busy preview env can starve
prod.

Two changes per route:

1. Wrap the upstream fetch in `next: { revalidate, tags }` so repeat
   calls inside the TTL are served from Next's data cache
   (.next/cache/fetch-cache, in-memory under output: 'standalone').
   Tagged so a future maintenance route can revalidateTag() without
   redeploying.

2. Set Cache-Control on the JSON response so browsers and any future
   CDN cache too. TTLs match each source's actual rate of change:

   - Sourcify: 7d / 30d SWR on 200, 30m / 1d SWR on 404. ABIs are
     tied to one-shot verifications and effectively immutable per
     address; the 30m 404 window lets a freshly verified contract
     surface inside an hour.
   - OpenChain: 7d / 30d SWR when every requested hash resolved,
     30m / 1d SWR when any hash came back null. Hash → canonical
     signature is a cryptographic fact, but the DB grows so misses
     deserve a short window.
   - b32: 1d / 7d SWR on 200, 30m / 1d SWR on 404. b32 is a
     community-curated GitHub repo where entries are added and
     occasionally corrected; a 1-day window keeps corrections from
     getting stuck for a week.

   5xx / timeout / network error responses carry no-store so an
   upstream blip doesn't poison the cache.

/api/b32 also switches from the apiClient.get wrapper (which doesn't
forward `next` options) to a direct fetch, matching the pattern in
the other two routes.

Known limitation: Next's data cache lives in the container fs and is
wiped on every deploy and instance restart. Each pod pays the cold
upstream cost for its first hit per (chainId, address). Persistent
cache via a custom cacheHandler (Redis / S3) is left as future work —
rate-of-change is low enough that the per-pod ephemeral cache should
pay for itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Agilulfo1820 Agilulfo1820 added the increment:patch PR adds backwards compatible bug fixes / patches label Jun 8, 2026
Agilulfo1820 and others added 2 commits June 9, 2026 10:41
Sourcify and OpenChain success cache drops from 7d to 1d so they
match — easier to reason about than three different windows. b32
shortens further to 1h since it's the most actively curated upstream
and corrections should propagate quickly. 404 windows (30m) are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-entry tags on the upstream fetch let us call `revalidateTag` after
seeing a 429, so Next's data cache doesn't sit on the rate-limit response
for the success TTL. 404 response Cache-Control drops from 30min to 5min
so freshly-verified contracts / newly-indexed signatures surface fast.
200 TTLs unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Base automatically changed from feat/decoding-pipeline to main June 9, 2026 15:11
@daithihearn

Copy link
Copy Markdown
Member

Closing this as it was implemented in a different PR

@daithihearn daithihearn closed this Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

increment:patch PR adds backwards compatible bug fixes / patches

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants