morphit/docs/adr/0020-batch-k-explorer.md

328 lines
13 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.

# 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_id` fields 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:
1. A public **block explorer** at `/explorer` — search by
`@username`, transaction id, or block number; polls real-time;
lazy-loaded.
2. **Cross-chain links** — completed orders linking to
appropriate external explorers for BTC/XMR; native Blurt
linking to our own.
3. **Volume + market depth** — "make Morphit feel like a real
exchange."
4. **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 `@alice` and `alice` are equivalent).
- Account name: lowercase, starts with letter, length 316.
- 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_transfer` op see raw JSON. The fallback link to
`blocks.blurtwallet.com` mitigates 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` — added `getBlock` +
`getTransaction` methods + types.
- `apps/web/src/lib/indexer/client.ts` — added
`getActivityVolume` wrapper and types.
- `apps/indexer/src/api/activity.ts``/v1/activity/volume`
endpoint 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_explorer` FAQ
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 `ops` array at, e.g., 1000 entries with "view earlier
on fallback" affordance. Deferred until reports.