13 KiB
ADR-0020: Block explorer, trading-activity stats, APR display, cross-chain links
Status: Accepted Date: 2026-04-29 Deciders: Agorise team (Claude collaborating) Supersedes: — Related:
- ADR-0008 (Phase 3b indexer architecture) — defines the feedback / orders schema this ADR's activity endpoint joins against.
- ADR-0011 (dynamic fee model) — touches the order schema's
external_tx_idfields that we link out to mempool.space / xmrchain.net for completed BTC/XMR trades. - ADR-0019 (release-trust-anchor) — same chain-direct, no-indexer-trust pattern this ADR's explorer reuses.
Context
Through Batch J the project had a frontend, an indexer, and a chat layer — but no surface for a curious passer-by to verify "what's actually happening on this Morphit instance?" or for a user to inspect the chain history of an order they were involved in. The user asked for four related things:
- A public block explorer at
/explorer— search by@username, transaction id, or block number; polls real-time; lazy-loaded. - Cross-chain links — completed orders linking to appropriate external explorers for BTC/XMR; native Blurt linking to our own.
- Volume + market depth — "make Morphit feel like a real exchange."
- APR display near the user's BP balance.
The user was clear about polling-vs-websocket ("polling is fine") and was open to design pushback on the depth-chart question.
Decision
Middle-ground explorer
The explorer renders Morphit-aware decoration for the ~16 custom_json op ids Morphit uses (order, order_replace, feedback, chat, operator_block, etc.) plus the three chain primitives that show up in normal usage (transfer, comment, vote). Anything else falls through to:
- Custom_json with unknown id → labeled "Other app" + raw-JSON fallback.
- Other native ops (witness votes, escrow, vesting) → labeled "Other chain op" + raw-JSON fallback.
This avoids the wide-coverage tail of building a generic Blurt
op renderer (every op type with proper field formatting would
take weeks). Users who hit the long tail get a working
raw-JSON view plus a "view on blocks.blurtwallet.com"
fallback link.
Routes:
/explorer— search landing./explorer/account/[name]— account view, polling 5s./explorer/block/[num]— block detail, immutable so no poll./explorer/tx/[id]— tx detail, immutable so no poll./explorer/activity— volume + listings histogram, polling 30s.
All routes are public, no login required, lazy-loaded (SvelteKit splits per-route by default; the chunks aren't downloaded until the user navigates).
Polling cadence
Per-page, fixed cadences:
| Page | Poll | Why |
|---|---|---|
| account | 5s | Real-time op stream feels live |
| activity | 30s | Coarse stats; faster wastes RPC traffic |
| block | none | Immutable once produced |
| tx | none | Immutable once produced |
All polls are visibility-aware (skip when document.hidden)
and cleaned up in onDestroy.
Chain-direct fetch (NOT via indexer)
Same posture as ADR-0019: the explorer reads from chain RPC
directly via BlurtClient, NOT from the indexer's /v1/...
endpoints. The indexer caches Morphit-specific state
(orderbook, feedback) but doesn't index every chain op.
Going chain-direct also means the explorer keeps working when
the indexer is down or experiencing replay lag.
The activity page is the one exception — its volume aggregation does need a SQL JOIN across feedback + orders (impossible from a single RPC call). It hits the indexer for that data only.
Cross-chain external explorer choices
| Asset | URL pattern | Rationale |
|---|---|---|
| BTC | https://mempool.space/tx/{txid} |
Open-source, no JS tracking, fast. |
| XMR | https://xmrchain.net/tx/{txid} |
Standard Monero community explorer. |
| BLURT (native) | /explorer/tx/{trxid} (own) |
Self-hosted; falls back to blocks.blurtwallet.com if not found. |
XMR caveat surfaced in UI: Monero transactions are encrypted by design. The external explorer can confirm a tx exists and was included in a block, but the inputs/outputs are private. We don't pretend otherwise in copy.
Per the user's correction: blockchain.blurt.world does NOT
exist. blocks.blurtwallet.com is the only Blurt fallback link
we surface.
Real-exchange feel: volume + listings histogram (NOT depth chart)
The user asked "make Morphit feel similar to a real exchange." The honest pushback is that Morphit isn't a matching engine — each order has its own payment methods, region, and price model. A traditional bid/ask depth chart would mislead users into thinking they could just "hit the offer" when they can't.
What we DO show on /explorer/activity:
- Trade count by asset over 7d / 30d / 90d windows (exact).
- Estimated volume per asset, computed as the midpoint of each completed order's amount range (since the chain doesn't carry exact fill amounts on feedback ops). Clearly labeled "estimated" in the UI.
- Listings histogram — current buy vs sell count per asset, rendered as horizontal split bars. Honest representation of "how many active offers" without faking exchange semantics.
UI copy explicitly explains that Morphit is P2P with negotiated terms and that each listing has its own constraints — so users understand what they're looking at.
APR computation
computeBlurtVestingApr(dgp) is a pure helper that takes the
chain's DynamicGlobalProperties and returns the current
APR for staked BLURT (Blurt Power) as a percentage.
Constants baked in from Blurt chain config:
- Inflation start: 9.5% annual.
- Decay: 1 basis point per ~250,000 blocks (4 micro-bps/block).
- Floor: 0.95%.
- Vesting share of inflation: 75%.
Computed once per MyBalanceCard.refresh() cycle (60s) using
the DGP that was already fetched for BP/MANA — no extra RPC
call. Display: "Currently earning N.NN% APR" sub-line under BP.
Per the user's note "ask the chain every day" — the inflation rate drifts by sub-basis-points per day, so a 24h cache would work, but the computation is so cheap (a few floating-point ops) that we just recompute every refresh. Same end-user experience.
Search-input parser
Pure helper parseSearchInput(raw) returns one of:
{kind: 'account', account}, {kind: 'txid', txid},
{kind: 'block', blockNumber}, or {kind: 'unknown', raw}.
Strict classification:
- Strips leading
@(so@aliceandaliceare equivalent). - Account name: lowercase, starts with letter, length 3–16.
- Block number: pure digits, positive, less than
Number.MAX_SAFE_INTEGER. - Txid: 40 hex chars (the Blurt format; not 64 BTC/XMR).
- Anything else →
unknown. Better to fail explicitly than guess wrong.
The submit handler dispatches to the right route based on the discriminated kind.
Param matchers
SvelteKit param matchers reject malformed URLs at routing time:
account(existing): the standard Blurt account regex.blocknum(new):/^[1-9][0-9]{0,18}$/.trxid(new):/^[0-9a-f]{40}$/lowercase only.
A garbage URL like /explorer/block/abc 404s before any RPC
call.
Op-decoration helper
Pure helper decorateOp(opName, opBody) returns
{kind, labelKey, isMorphitOp}. Uses OP_IDS from the existing
config. Smoke covers all 21 decoration kinds plus the
fallthrough cases.
isMorphitOp drives a tinted background on Morphit-specific op
labels — visual signal that this op is part of Morphit's
protocol rather than generic Blurt activity.
Consequences
Positive
- Closed the "where's the explorer" gap. The project now has a user-facing surface that demonstrates "everything is on chain" rather than just claiming it.
- Cross-chain links reduce friction in completed-trade UX. A buyer who paid via BTC sees a one-click "View on explorer" link to mempool.space.
- APR display gives stake-holders concrete feedback on what their BP earns — was previously invisible.
- Activity stats give an honest "how active is this instance" signal without faking exchange semantics.
- Reuses the chain-direct-not-indexer posture from ADR-0019, consistent with the project's trust model.
Negative
- The middle-ground explorer can't render the long tail of
Blurt op types beautifully. Users who hit, say, an
escrow_transferop see raw JSON. The fallback link toblocks.blurtwallet.commitigates but doesn't eliminate the rough edge. - The activity page caps orderbook fetch at limit:100. If the ecosystem grows past that for one (asset, side), the histogram undercounts. Acceptable for early launch; documented in the component for future expansion.
- Volume is an ESTIMATE — the chain doesn't carry exact fill amounts on feedback ops. We label this clearly in the UI but it's still an asterisk on what looks like a precise number.
- The trust posture for explorer reads is "trust the RPC node" same as the rest of the app. A malicious RPC could lie about account balances, op history, etc. Detection is best-effort (the account-name guard catches one class). Same posture as pre-Batch-K and not regressed.
- Long-running tabs accumulate ops in memory unboundedly. Acceptable for practical use; tab close clears it.
Trade-offs explicitly considered
- Build a generic Blurt op renderer vs middle-ground. Rejected the generic renderer for scope reasons; the long tail of op types doesn't materially help Morphit users. The middle-ground covers the 95% case beautifully.
- WebSocket push vs polling. Per the user, polling is fine. Polling is also simpler, doesn't require a streaming-RPC plugin (not all Blurt nodes expose one), and visibility-aware polling is gentle on chain RPC bandwidth.
- Depth chart vs listings histogram. Pushed back on the user's "depth chart" framing because Morphit isn't a matching engine. User accepted volume + listings-histogram alternative.
- Indexer endpoint vs chain-direct for explorer reads. Chose chain-direct for everything except the activity-page volume aggregation (which needs SQL JOIN). Same posture as ADR-0019.
- APR cache cadence. Considered 24h cache, decided to recompute every refresh (60s) — math is trivial, no extra RPC call needed.
Implementation
apps/web/src/lib/blurt/apr.ts— pure APR computation.apps/web/src/lib/explorer/urls.ts— external + internal explorer URL builders with strict input validation.apps/web/src/lib/explorer/search.ts— search-input parser.apps/web/src/lib/explorer/decorate.ts— op-decoration helper.apps/web/src/lib/explorer/listingsHistogram.ts— pure aggregator for the listings bar chart.apps/web/src/lib/blurt/client.ts— addedgetBlock+getTransactionmethods + types.apps/web/src/lib/indexer/client.ts— addedgetActivityVolumewrapper and types.apps/indexer/src/api/activity.ts—/v1/activity/volumeendpoint with three windowed SQL aggregations.apps/web/src/params/{blocknum,trxid}.ts— route matchers.apps/web/src/routes/explorer/+page.svelte— search landing.apps/web/src/routes/explorer/account/[name=account]/+page.svelte— account view with 5s polling.apps/web/src/routes/explorer/block/[num=blocknum]/+page.svelte— block detail.apps/web/src/routes/explorer/tx/[id=trxid]/+page.svelte— tx detail with show/hide raw JSON per op.apps/web/src/routes/explorer/activity/+page.svelte— trading activity page.apps/web/src/lib/components/MyBalanceCard.svelte— APR sub-line under BP.apps/web/src/lib/components/ChatMessage.svelte— funds_sent pill "View on explorer" link routing per asset.apps/web/src/routes/+layout.svelte— footer link added.apps/web/src/routes/my/orders/+page.svelte— header link to user's account on explorer.apps/web/src/lib/utils/faqIndex.ts—block_explorerFAQ key registered.- Smokes:
apr-smoke.ts— 13 scenarios (inflation curve, APR formula, formatter).explorer-urls-smoke.ts— 21 scenarios (URL builders + injection rejection).explorer-search-smoke.ts— 20 scenarios (parser classification + edge cases).explorer-activity-smoke.ts— 19 scenarios (histogram aggregation + decorateOp).
- i18n: 109 new keys × 10 locales = 1090 strings (88 page keys + 21 op labels). Drift = 0.
- Audit doc:
docs/audit/2026-04-29-batch-k-explorer.md.
Open questions / future work
- Generic Blurt op renderer: every Blurt op type with properly formatted fields, replacing raw-JSON fallback. Substantial work; deferred until users complain.
- Real depth/price-vs-market display: requires a market- price feed (currently no canonical source in the Blurt ecosystem). Deferred.
- Tx-by-id without tx-index plugin: walk blocks ourselves to find a tx. Quadratic if naive; needs a sensible search bound. Deferred.
- Activity page cap > 100 listings per side: if the ecosystem grows, expand to paginated fetch + accumulate. Deferred.
- Long-running tab memory cap on /explorer/account: cap
the
opsarray at, e.g., 1000 entries with "view earlier on fallback" affordance. Deferred until reports.