9.1 KiB
Audit — Batch K: block explorer + APR + cross-chain links + activity
Date: 2026-04-29 Scope: Code introduced for ADR-0020. Builds on the methodology used in the Batch I audit, the post-Batch-I follow-up audit, and the Batch J audit.
| Surface | Files |
|---|---|
| APR helper | apps/web/src/lib/blurt/apr.ts |
| External-explorer URLs | apps/web/src/lib/explorer/urls.ts |
| Search-input parser | apps/web/src/lib/explorer/search.ts |
| Op-decoration helper | apps/web/src/lib/explorer/decorate.ts |
| Listings histogram | apps/web/src/lib/explorer/listingsHistogram.ts |
| BlurtClient extensions | apps/web/src/lib/blurt/client.ts (getBlock, getTransaction) |
| Activity API | apps/indexer/src/api/activity.ts |
| Indexer client wrapper | apps/web/src/lib/indexer/client.ts (getActivityVolume) |
| Param matchers | apps/web/src/params/{blocknum,trxid}.ts |
| Explorer pages | apps/web/src/routes/explorer/{,/account/[name],/block/[num],/tx/[id],/activity}/+page.svelte |
| Wired surfaces | Footer (+layout.svelte), /my/orders, ChatMessage.svelte funds_sent pill |
| MyBalanceCard | APR display |
Methodology
Same as prior audits:
- STRIDE per surface.
- Hostile-input sweep on every parser / consumer.
- Chain-direct re-pass: assume RPC nodes are attacker-controlled.
- Cross-tab race-condition review.
- "Experienced black hat hacker" lens.
Severity: HIGH / MEDIUM / LOW / INFO / NOTED.
Findings — applied during this audit
K-1 (LOW, account/block/tx pages) — non-string timestamp would throw or render garbage
Surface: /explorer/account/[name]/+page.svelte formats each
op's timestamp with op.timestamp.endsWith('Z') ? op.timestamp : \${op.timestamp}Z`. /explorer/block/[num]/+page.svelteand/explorer/tx/[id]/+page.svelterender{block.timestamp}Zand{tx.expiration}Z` as plain interpolations.
Issue: The chain RPC might return a non-string timestamp
(e.g. null, a numeric epoch from a non-conformant proxy). On
the account page, .endsWith() would throw and break the entire
ops list rendering. On the block/tx pages, the interpolation
would render nullZ or [object Object]Z — visually broken
but not exploitable.
Fix:
- Account page: when materializing each
OpRow, coercehop.timestampto a string at the source:typeof hop.timestamp === 'string' ? hop.timestamp : ''. Empty string passes the.endsWith()check harmlessly. -
- Block + tx pages: render with type-guard:
{typeof block.timestamp === 'string' ? \${block.timestamp}Z`- '—'}
and same fortx.expiration`.
Severity post-fix: N/A — closed.
Findings — accepted as-is
K-2 (NOTED, /explorer/tx) — XSS via raw-JSON display
Reviewed. <pre>{JSON.stringify(op[1], null, 2)}</pre> uses
Svelte's default text interpolation, which HTML-escapes. Even if
op[1] contained {"x": "<script>"}, the rendered output
literal-escapes the angle brackets. Confirmed safe.
K-3 (NOTED, /explorer/account) — RPC trust
The account page reads acct.balance, acct.vesting_shares,
etc. from the RPC response. A malicious RPC could substitute
another account's data. The page enforces the same audit-fix S-1
guard as MyBalanceCard: if (acct.name !== account) throw new Error(...). If a hostile RPC plays back a different
account's name, we refuse to render. Confirmed safe.
K-4 (NOTED, polling) — cleanup correct
Both /explorer/account/[name] and /explorer/activity clear
their setInterval handles in onDestroy, set the local timer
ref to null after clear. Visibility-aware guard
(document.hidden) skips the poll body when the tab is in the
background. Confirmed clean.
K-5 (NOTED, search dispatch) — no injection path
parseSearchInput() returns a discriminated union with strict
classification. URL builders return null on malformed input.
The submit handler explicitly null-checks before goto().
Hostile pasted text → 'unknown' → friendly error message.
Confirmed safe.
K-6 (NOTED, activity SQL) — parameter binding
The volume query uses WHERE f.created_at > NOW() - $1::interval
with the window value passed as a positional parameter. Postgres
parameter binding handles all escaping; no string interpolation
of user input into SQL. Confirmed safe.
K-7 (NOTED, getTransaction) — graceful null on tx-index-less nodes
Some Blurt RPC nodes lack the account_history plugin's
get_transaction method (it requires a non-default tx-index
plugin). The frontend's BlurtClient.getTransaction wraps the
call in try/catch and returns null on RPC error rather than
propagating. /explorer/tx/[id] surfaces this as the
not_found UI state with a fallback link to
blocks.blurtwallet.com. Confirmed correct fallback.
K-8 (INFO, APR computation) — overflow safety
computeBlurtVestingApr does multiply-then-divide on
chain-supplied numbers. Worst-case realistic inputs stay within
float64's safe range. A hostile RPC returning bizarre numbers
wouldn't cause a security issue, just a display oddity (a NaN
formatted as "—" by formatApr). Confirmed bounded.
K-9 (NOTED, decorateOp hostile op body) — defensive against null
decorateOp(opName, opBody) casts opBody to
Record<string, unknown> | null and explicitly checks body && typeof body === 'object' before reading body.id. Smoke
covers the null-body case. Confirmed defensive.
K-10 (NOTED, external-explorer URL injection) — strict regex
externalExplorerUrl validates txids with /^[0-9a-fA-F]{64}$/
for both BTC and XMR; account names with the standard Blurt
regex; block numbers as positive-integer only. Anything failing
validation returns null and the caller hides the link.
Confirmed no injection vector.
K-11 (NOTED, block render) — defensive iteration
{#each tx.operations ?? [] as op} uses the ?? [] fallback so
a missing operations field doesn't crash. Inside, op[0] and
op[1] are passed to decorateOp which handles all malformed
shapes by returning native_unknown. Confirmed defensive.
K-12 (NOTED, multi-tab) — independent state
If the user opens two tabs of the same explorer account page,
each maintains its own ops array, oldestSeqLoaded cursor,
and pollTimer. No shared global state to race on. Confirmed
independent.
K-13 (NOTED, long-running tab memory) — bounded by user behavior
A tab left open with the account page polling and the user
clicking "Load more" repeatedly will grow the ops array
unboundedly. In practice, the user closes the tab; growth is
bounded by their patience. The fix would be a circular buffer
capped at, say, 1000 ops with a "view earlier on fallback"
affordance — overkill for the v1. Documented as acceptable for
early launch.
K-14 (NOTED, listings histogram limit) — undercounts beyond 100
The activity page fetches getOrderbook({ limit: 100 }). If
there are more than 100 active listings of one (asset, side),
the histogram undercounts. Documented in the activity page
component comment. Acceptable for early launch — orderbooks of
that size aren't the current scale.
Cross-surface findings
CS-K-1 (NOTED) — Lazy-loading respects no-login posture
The /explorer route is its own SvelteKit chunk; users who don't
visit /explorer never download its code. Confirmed by route
structure (separate +page.svelte per node). The chunk also has
no sign-in dependencies — it doesn't touch keystore or
isUnlocked stores. Public, anonymous, lazy. ✓
CS-K-2 (NOTED) — Polling cadences vary by surface, by design
| Surface | Cadence | Why |
|---|---|---|
| /explorer/account/[name] | 5s | Op stream feels real-time |
| /explorer/activity | 30s | Coarse stats; faster doesn't help |
| /explorer/block/[num] | none | Blocks are immutable |
| /explorer/tx/[id] | none | Txs are immutable |
Each is visibility-aware. No coordinated multi-surface poller is needed — Morphit's pattern is one timer per page. Confirmed appropriate.
CS-K-3 (NOTED) — APR computation respects "no third-party endpoints"
The APR helper computes from chain DGP (already fetched by
MyBalanceCard.refresh() for BP/MANA). No new chain calls, no
external services. Inflation curve constants baked in (Blurt
chain config). Confirmed self-contained.
Smoke regression posture
- 980 total scenarios passing (was 907 pre-Batch-K; +13 APR + 21 explorer URLs + 20 explorer search + 19 explorer activity).
- Typecheck clean, no new errors beyond the pre-existing baseline.
- i18n drift = 0 across 1810 keys × 10 locales.
Outstanding (not in this audit's scope)
- Batch I H2 — WebHID transport hardware probe (independent).
- External pre-launch audit by a security firm.
- Phase G mobile PWA polish — gated on this campaign closing.
- Future: tx-by-id fetching from any RPC node — currently requires a node with the tx-index plugin. Could improve by walking blocks ourselves; deferred.
- Future: depth-style price-vs-market histogram — requires a market-price feed (currently unavailable in Morphit's ecosystem); deferred.
Sign-off
This audit closes 1 finding (1 LOW). 13 findings reviewed and accepted as-is (10 NOTED-safe, 1 INFO, 2 NOTED-bounded-by-design). No findings remain open on Batch K surfaces.
Batch K considered shippable.