14 KiB
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_URLconfig, 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-sovereignmorphit_nativesource 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:
- 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.
- Listing fees (quoted as "~25 cents USD" in Plan v1.3) can be computed in the chosen fee currency (BTC, XMR, or BLURT).
- Order filters like "show offers within 5% of market price" work.
- 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.comandapi.coinmarketcap.combeing 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-srccurrently allows'self'+https:for the Blurt RPC pool. Adding two more well-known origins increases the attack surface. - Rate limits. CoinGecko free tier is 10–30 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 * quantitycalculation 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:
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/pricesis 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:
-
BlurtOracleProvider(default for privacy mode) — readsmorphit_price_v1ops 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. -
ApiRelayProvider(default for standard mode) — calls$CURRENT_INDEXER/api/pricesvia the endpoint rotator, which means price requests automatically fail over across community indexers just like RPC requests do. -
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_v1attestation 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_jsonimmutability (applies tomorphit_price_v1the same as other ops). $lib/net/endpoints.ts— the endpoint-rotation pattern theApiRelayProviderwill 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—CompositeCachedPriceSourceaggregates results from a list of fetcher providers and serves cached, time-stamped quotes.apps/indexer/src/indexer/price/coingeckoFetcher.tsandapps/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 reactivepriceStore+getPrice()API inindex.tsdefaults tofallbackProviderat 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 toDirectClientProviderin 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.tsandcoingecko.tsare 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)."