morphit/docs/audit/2026-04-29-batch-k-explorer.md

237 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.svelte` and
`/explorer/tx/[id]/+page.svelte` render `{block.timestamp}Z` and
`{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`, coerce
`hop.timestamp` to 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 for `tx.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.