morphit/docs/adr/0004-price-feeds.md

301 lines
14 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-0004: Price feed architecture
**Status:** Accepted (implemented in `apps/indexer/src/indexer/price/`)
**Date:** 2026-04-17 (proposed); 2026-05-06 (status updated — Phase 3 indexer-side shipped); 2026-05-25 (cp138 — frontend wiring partially complete; live prices on indexer at `/v1/price/...`, frontend still fallback-only pending an `ApiRelayProvider` + Settings opt-in)
**Deciders:** project maintainer
> **2026 forward note (cp367 — Klingex removed; cp376 — multi-source
> median):** This ADR was
> written when Klingex (the Blurt-community-run CEX) was BLURT's
> primary external upstream. Klingex went out of business in 2026.
> The Klingex fetcher, its `MORPHIT_INDEXER_KLINGEX_BASE_URL`
> config, and its slot in the chain were removed. Initially CoinGecko
> became the sole external source; cp376 then replaced that
> single-upstream risk with an **outlier-rejected median across many
> independent external feeds** (Coingecko, CoinPaprika, CryptoCompare
> for every asset; plus Kraken/Binance/Coinbase/OKX/Bybit where the
> asset is listed; plus CoinLore and the key-gated CoinCap/Messari
> when configured), with the opt-in self-sovereign `morphit_native`
> source and the static floor behind it. Any feed that returns
> nothing is dropped from the median, so no single provider can ban,
> rate-limit, or skew the published price. References to Klingex
> below are retained as the original decision-record context; read
> "Klingex → Coingecko" as "the external-feed median" for current
> behavior.
## Context
Morphit needs USD-equivalent prices for BTC, XMR, and BLURT so that:
1. A buyer asking "I want $50 worth of BLURT" can see how many BLURT
that is at current market rates before agreeing to a trade.
2. Listing fees (quoted as "~25 cents USD" in Plan v1.3) can be
computed in the chosen fee currency (BTC, XMR, or BLURT).
3. Order filters like "show offers within 5% of market price" work.
4. The orderbook can warn on obviously off-market listings so new
users don't get scammed.
Typical crypto apps solve this by calling `api.coingecko.com` or
`api.coinmarketcap.com` directly from the browser. Morphit's
architecture makes that choice non-obvious because:
- **Privacy cost.** Every page load would reveal the user's IP address
to CoinGecko and/or CoinMarketCap. Both log these calls. A user on
Tor Browser would either (a) leak their exit-node IP to these
services or (b) have the request fail when CG/CMC blocks
known-Tor IP ranges, breaking the feature entirely.
- **Reliability cost.** A client-side direct call creates a runtime
dependency on `api.coingecko.com` and `api.coinmarketcap.com` being
reachable. Phase 1's REVIEW explicitly listed "no runtime dependency
on morphit.io" as a non-negotiable claim; by the same logic, no
runtime dependency on any single-source third-party service should
be baked in.
- **CSP cost.** Our strict `connect-src` currently allows `'self'` +
`https:` for the Blurt RPC pool. Adding two more well-known
origins increases the attack surface.
- **Rate limits.** CoinGecko free tier is 1030 req/min per IP.
A shared Tor exit IP would blow through this in seconds.
CoinMarketCap's free tier requires an API key; embedding one in
the client bundle leaks it immediately.
- **BLURT thin float.** BLURT market depth on listed exchanges
(klingex.io primarily) is thin enough that a naive
`last_trade_price * quantity` calculation can be off by 10-30% vs.
realistic execution price. A real pricing layer would need to also
estimate depth-weighted prices.
## Decision
**Phase 2 ships a provider-swappable `$lib/prices/` interface** with a
hardcoded-fallback provider as the default. Real network-fetching
providers and on-chain oracle providers land in Phase 3 alongside the
orderbook.
The interface:
```typescript
interface PriceProvider {
readonly name: string;
getPriceUsd(symbol: 'BTC' | 'XMR' | 'BLURT'): Promise<PriceQuote>;
}
interface PriceQuote {
symbol: 'BTC' | 'XMR' | 'BLURT';
usd: number;
fetchedAt: number; // Unix ms
source: string; // provider name; shown in the "prices updated X ago" tooltip
}
```
The UI displays a **"prices updated X seconds ago"** indicator
wherever a USD-quoted value is shown, colored yellow if the quote is
>60s stale and red if >10min stale. This turns price staleness into
a visible user signal rather than a silent correctness bug.
## Alternatives considered (kept for Phase 3 decision)
### Option A — Direct client-side CoinGecko + CoinMarketCap
- **Pros:** simplest; no infrastructure; always fresh; average of two
sources is robust against single-provider manipulation.
- **Cons:** privacy-violating by construction (two third-party
services see every user's IP on every page load); Tor users are
broken entirely (CG/CMC block known Tor exits); CSP widening;
CMC API key leaks into client bundle; rate limits make shared IP
infrastructure (corporate networks, VPNs, public WiFi) unreliable.
- **Verdict for Phase 3:** not recommended as sole strategy. Could
work as an **optional user-selectable** provider for users who
want freshest data and accept the privacy tradeoff — ship it behind
a Settings toggle, default off.
### Option B — `morphit.io/api/prices` server endpoint
- **Pros:** user IPs hidden from price sources; CMC API key
server-side; single authoritative source; 60-second server-side
cache keeps us within CG/CMC free-tier rate limits even at
arbitrary user scale; consistent prices across all users (no
quote-to-quote variance).
- **Cons:** requires a server component; adds a single point of
failure outside the user's control; violates the "no runtime
dependency on morphit.io" promise by making pricing depend on the
morphit.io origin being up.
- **Verdict for Phase 3:** reasonable as the **default** provider,
*if* we also ship Option C as a fallback that kicks in when
`/api/prices` is unreachable. Any community-run mirror (.onion,
.loki, etc.) can run its own `/api/prices`; clients discover via
the configured endpoint list rather than hardcoded to morphit.io.
### Option C — On-chain `morphit_price_v1` custom_json
- **Pros:** pure P2P, no third-party dependency at request time;
verifiable history on Blurt; works from Tor/Lokinet/I2P without
caveat; withstands any single-provider compromise because multiple
oracle accounts can publish and clients can median-of-N them.
- **Cons:** staleness is bounded by oracle posting frequency (we'd
target ~1 post per 5 minutes per oracle); requires running a
price-oracle service (a tiny Go program that calls CG+CMC and
broadcasts the result). Running at least 3 independent oracles is
needed to prevent manipulation by a single compromised one.
- **Verdict for Phase 3:** **strong default** for privacy-preserving
users; runs on community Morphit nodes transparently; single
oracle start is acceptable for launch, more added as community
grows.
### Option D — Hardcoded fallback only
- **Pros:** trivially private; trivially reliable; zero dependencies.
- **Cons:** BLURT has thin float and real price moves ~10-30% on
any given week; using a static number means a buyer asking for
"$50 of BLURT" could end up with anywhere from $35 to $65 of BLURT
at actual execution. Tolerable when no orders exist (Phase 2);
unacceptable once orders do (Phase 3).
## Phase 3 plan (NOT YET DECIDED; candidate)
Ship three providers, let the user pick:
1. **`BlurtOracleProvider`** (default for privacy mode) — reads
`morphit_price_v1` ops from a configured oracle account list
(seeded with a handful of community oracles, user-editable in
Settings the same way RPC endpoints are). Picks the median of the
N most recent quotes per symbol, filtering any older than 15 min.
2. **`ApiRelayProvider`** (default for standard mode) — calls
`$CURRENT_INDEXER/api/prices` via the endpoint rotator, which
means price requests automatically fail over across community
indexers just like RPC requests do.
3. **`DirectClientProvider`** (opt-in only) — direct CoinGecko +
CoinMarketCap average, behind a Settings toggle with a clear
privacy warning. User's explicit choice to widen the CSP for
freshest data.
All three speak the `PriceProvider` interface; swapping is a
one-line change in `$lib/prices/index.ts`.
## Consequences of the Phase 2 decision
### Positive
- No privacy regression in Phase 2. No new third-party origins in
the CSP. No API keys shipped.
- Interface is stable; Phase 3 adds providers without touching any
consumer code.
- "Prices updated X seconds ago" indicator puts staleness in front
of users from day one, which trains them to trust fresh data and
be cautious of stale.
### Negative
- The hardcoded fallback is, by definition, stale. Plan v1.3 pegs
BLURT/USD at $0.002. If real-world BLURT settles at $0.0025 at
Phase 2 launch, every displayed USD equivalent is off by 25%.
**Mitigation:** Phase 2 has no order-posting flow yet; the only
place USD equivalents are visible is the FAQ's description of the
$0.25 fee. Static error there is academic.
- BTC/XMR fallback values are likewise static. Updating them
requires a code change and release.
### Follow-up work
- Phase 3: decide between Options B, C, or both; implement.
- Phase 3: add a Settings control that lets the user choose which
provider to use (if we ship multiple).
- Phase 3: CMC API key management (if Option B chosen) — document
key rotation, embed in relay config, not client.
- Phase 5: consider adding an optional `morphit_price_oracle_v1`
attestation signing so an oracle can be verifiably associated
with a known operator (optional, but useful for high-stakes
price-sensitive actions).
## References
- Plan v1.3 — BLURT/USD $0.002 hardcoded fallback.
- ADR-0001 — `custom_json` immutability (applies to
`morphit_price_v1` the same as other ops).
- `$lib/net/endpoints.ts` — the endpoint-rotation pattern the
`ApiRelayProvider` will reuse.
## Amendments
### 2026-05-09 (Part 90, Category I ADR-fidelity audit) — reconcile with shipped state
The status header says "implemented in `apps/indexer/src/indexer/price/`"
but the body's "Phase 3 plan (NOT YET DECIDED; candidate)" section
described a different architecture than what shipped. This
amendment reconciles the two without rewriting history.
**What actually shipped (Phase 3, indexer-side):**
- `apps/indexer/src/indexer/price/compositeSource.ts`
`CompositeCachedPriceSource` aggregates results from a list of
fetcher providers and serves cached, time-stamped quotes.
- `apps/indexer/src/indexer/price/coingeckoFetcher.ts` and
`apps/indexer/src/indexer/price/klingexFetcher.ts` — concrete
upstream fetchers, each implementing the same minimal
fetcher interface; the operator picks which to enable via
config flags.
- `apps/indexer/src/indexer/price/factory.ts` — assembles the
composite source from operator config at boot.
The indexer is the privacy boundary: it makes the upstream
calls (CoinGecko, Klingex), and frontend pages read the
indexer's `/v1/price/...` endpoint via the standard endpoint
rotator. This is the `ApiRelayProvider` design from the
"Phase 3 plan" section, generalized — there's no separate
`BlurtOracleProvider`, since the on-chain oracle approach
proved unnecessary for Phase 3 launch.
**What also shipped (frontend, `$lib/prices/`):**
- `apps/web/src/lib/prices/index.ts` — provider registry +
`setProvider()` / `getQuote()` API.
- `apps/web/src/lib/prices/providers/fallback.ts` — the
hardcoded fallback the original ADR specified for Phase 2.
**This is the only provider actually wired into the live
UI today.** The reactive `priceStore` + `getPrice()` API
in `index.ts` defaults to `fallbackProvider` at boot and
there is no Settings toggle to swap it.
- `apps/web/src/lib/prices/providers/coingecko.ts`
reference implementation of a direct-client price fetcher
(corresponds to `DirectClientProvider` in the original
Phase 3 plan). **Not currently imported anywhere.**
Retained as RFC code for a future "users who want freshest
prices and accept the IP leak" opt-in.
- `apps/web/src/lib/prices/providers/composite.ts` — reference
implementation of a multi-upstream chainer. **Not
currently imported anywhere.** Retained as RFC code for
the same future opt-in path.
The frontend serves prices from the static `fallback` quotes
only. To get live prices into the UI, the next step is to
ship an `ApiRelayProvider` that calls the indexer's
`/v1/price/...` endpoint and wire it via a Settings toggle
("opt-in to live prices? may slightly degrade privacy by
relaying through your home instance"). Until that ships, the
ADR's user-facing privacy promise (indexer is the privacy
boundary, frontend never speaks to CoinGecko directly) is
trivially upheld because the frontend doesn't fetch prices
at all.
### 2026-05-25 (cp138 audit) — accuracy correction to the 2026-05-09 amendment
The 2026-05-09 amendment overstated the frontend wiring. It said
"frontend defaults to fallback prices unless the user explicitly
opts into the indexer-relayed feed via Settings" and "a direct-
CoinGecko provider also exists and ships [...]; a user who wants
freshest prices and is OK with the IP leak can flip to it." In
reality:
- No Settings toggle for price-provider switching exists.
- `setProvider()` is exported but uncalled.
- `composite.ts` and `coingecko.ts` are unimported (and so unused).
- The frontend serves fallback quotes only.
That overstatement was caught by the cp138 deep audit's orphan-file
sweep. The amendment text above has been corrected to describe the
actual state — RFC code parked for a future Settings opt-in, with
no semantic regression vs. the original Phase 2 promise (which was
"fallback only at Phase 2 launch").
The follow-up work is tracked in `REVISIT-LIST.md` as
"Ship `ApiRelayProvider` + Settings toggle (drift-resolved
in cp138; future Phase-3-completion work)."