13 KiB
ADR-0042 — Multi-asset morphit_native price source (cp130: BTC + XMR alongside BLURT)
Status: Accepted (shipped 2026-05; pre-launch hardening campaign)
Date: 2026-05-23 Deciders: project maintainer (Ken) Related: ADR-0039 (self-sovereign pricing — designed the generic factory; cp127 wired BLURT only); ADR-0040 (denomination configurability); ADR-0041 (cross-instance peer disagreement — already supports per-asset sampling, cp130 extends the wiring to spawn one monitor per asset).
Context
ADR-0039 designed createMorphitNativeFetcher({ asset, denominationFiat, ... }) as a generic factory — any (asset, fiat) pair, not BLURT-specific. cp127 wired BLURT only because that was the immediate use case (the listing-fee USD echo).
cp130 wires the other two primary tradable assets — BTC and XMR — through the same generic factory. The wiring change is small (the factory already supported it); what matters is the supporting infrastructure: per-asset upstream-chain choices, per-asset static-floor config, multi-asset peer monitoring.
The motivating use case is operator transparency. With /v1/price/morphit-native/receipt?asset=BTC returning a real derivation, operators can inspect what BTC/USD price their indexer would surface for any future feature that wants BTC price data (orderbook USD echo, fee oracle for non-BLURT fee modes, etc.). cp130 doesn't ship any UI consumer of BTC/XMR prices — that's deferred to cp131+ — but the backend is ready.
Decision
Generic asset factory
apps/indexer/src/indexer/price/factory.ts introduces:
createAssetPriceSource(config, options, db?)— generic builder takingAssetPriceSourceOptions(asset, coingeckoCoinId, enableKlingex, staticFloor)CP130_ASSET_DEFAULTS— per-asset defaults for the cp130 launch set (BLURT, BTC, XMR)createMultiAssetPriceSources(config, db?)— returnsMap<string, BlurtPriceSource>keyed by asset tickercreatePriceSource(config, db?)— preserved as a backwards-compat wrapper for BLURT-only callers (listing-fee endpoint)
Per-asset upstream chains
| Asset | Klingex | Coingecko | morphit_native | Static floor |
|---|---|---|---|---|
| BLURT | ✅ (primary; Klingex's flagship pair is BLURT/USDT) | ✅ (fallback) | ✅ (opt-in) | ✅ (last resort) |
| BTC | ❌ (Klingex doesn't trade BTC/USDT at scale) | ✅ (primary; coinId='bitcoin') | ✅ (opt-in) | ✅ |
| XMR | ❌ (same; Klingex BLURT-only) | ✅ (primary; coinId='monero') | ✅ (opt-in) | ✅ |
The enableKlingex flag in AssetPriceSourceOptions controls per-asset Klingex inclusion. BTC and XMR get a 3-tier chain (Coingecko → morphit_native → static); BLURT keeps its 4-tier chain (Klingex → Coingecko → morphit_native → static).
Coingecko generalization
coingeckoFetcher.ts previously hardcoded vs_currencies=usd in the URL and accessed .usd in the response. cp130 generalizes:
- New
vsCurrency: stringfield inCoingeckoConfig— lowercased per Coingecko API convention - URL:
vs_currencies=${vsCurrency} - Response extraction:
body[coinId][vsCurrency]
This means the factory can pass config.priceFeedDenominationFiat.toLowerCase() as the vs_currency, so a EUR-denominated instance gets EUR-priced BTC/XMR feeds directly from Coingecko (CG supports many vs_currencies natively).
Per-asset static-floor config
Two new env vars, both with sane USD-shaped defaults:
| Env var | Default | Purpose |
|---|---|---|
MORPHIT_INDEXER_PRICE_FEED_BTC_STATIC_FLOOR |
60_000 |
Fallback BTC/USD price when all live upstreams have failed and nothing has cached since boot |
MORPHIT_INDEXER_PRICE_FEED_XMR_STATIC_FLOOR |
200 |
Same for XMR/USD |
Operators in non-USD denominations should override these to their unit (a EUR-denominated instance might set BTC_STATIC_FLOOR=55_000 for ~BTC/EUR; a XAU-denominated instance might set BTC_STATIC_FLOOR=0.011 for BTC-per-ounce-of-gold). Static floors are intentionally rough — they never surface in normal operation; the composite source consults them only when every live upstream has failed.
The existing MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR continues to be the BLURT static floor (preserved for backwards compatibility).
Per-asset denomination — NOT added (item #3 collapsed)
Ken's "six bullet" list included item #3 (per-asset denomination configurability). cp130 collapses this into "global denomination applies to all assets" instead of adding per-asset configurability:
- An operator who sets
priceFeedDenominationFiat=EURgets BLURT/EUR, BTC/EUR, XMR/EUR — a coherent display unit across the whole instance. - The use case for per-asset denomination ("operator wants BTC priced in USD but BLURT in EUR") is plausible but speculative. No concrete user request.
- Adding per-asset config would mean either 16 env vars (one per asset; bad for grandma's grandfather the operator) or a JSON-map env var (still operator complexity).
If a concrete need appears later, revisit. The factory's denominationFiat parameter is per-asset already (it accepts a string per call site); only the wiring code in factory.ts:createMultiAssetPriceSources hardcodes config.priceFeedDenominationFiat for all assets. Replacing that hardcoding with a per-asset map is a small change when motivation exists.
Multi-asset peer monitor wiring
cp129's peerPriceMonitor.ts already supports per-asset operation — the module's signature is runPeerPriceSampleCycle(cfg) with asset and denominationFiat in the cfg. cp130 wires main.ts to spawn one monitor instance per (asset, denomination_fiat) pair when peer monitoring is enabled:
for (const [asset, source] of multiAssetSources) {
const stop = startPeerPriceMonitor({ db, priceSource: source, asset, denominationFiat: ... });
stopPeerPriceMonitors.push(stop);
}
The schema-v36 price_peer_observations table already supports per-asset rows (indexed on (asset, denomination_fiat, observed_at DESC)); no schema change needed. Each per-asset monitor independently queries peers' receipt endpoint with that asset parameter, stores observations tagged with the asset, and alerts on per-asset disagreement.
Receipt endpoint
/v1/price/morphit-native/receipt?asset=BTC&denomination_fiat=USD already worked pre-cp130 — the endpoint calls deriveMorphitNativePrice(asset, denomination_fiat, ...) directly with the query params. cp130 doesn't change the endpoint code; it makes the backend able to actually serve meaningful BTC and XMR derivations because the upstream chain now exists for those assets.
Resilience scenarios
Operator queries BTC/USD via receipt endpoint
deriveMorphitNativePrice(asset='BTC', denomination_fiat='USD', ...)runs the Tier 1/2/3 cascade against on-platform BTC-vs-USD-fiat orders (Tier 1) and BTC-vs-stablecoin orders (Tier 2) just like BLURT.- Operator sees the derived price + contributing-traders + tier_used.
- Forensics work identically to BLURT.
Operator on EUR denomination
- Coingecko URLs use
vs_currencies=eurfor all three assets — CG returns EUR-priced BLURT, BTC, XMR directly. - morphit_native fetcher's Tier 1 looks for BLURT-vs-EUR / BTC-vs-EUR / XMR-vs-EUR orders.
- Tier 2 (stablecoin-anchored) uses the same stablecoinKeys list (USDT/USDC/DAI by default) — they're USD-pegged, so EUR-denominated Tier 2 results will be off by the USD/EUR ratio. Operator should either accept this (Tier 2 is supplementary) or set
priceFeedStablecoinKeys=''to disable Tier 2 entirely on EUR-denominated instances. (Same caveat as cp128 ADR-0040; nothing new in cp130.)
Coingecko 429-rate-limits the BTC query
- Returns null from the Coingecko upstream for BTC.
- Composite source falls through to morphit_native (if enabled) or the BTC static floor.
- BLURT and XMR queries continue independently (each has its own composite source instance and its own request to Coingecko — they're not bound to the same fetch).
morphit_native disagreement between BTC peers
- Defense F's per-asset monitor independently watches BTC observations.
- If my indexer's BTC/USD derivation disagrees with the peer median by >25% sustained for >4 hours, the alert fires with the BTC asset tag.
- BLURT monitor + XMR monitor continue independently.
One asset's morphit_native data is too thin
- E.g. only 1-2 BTC-vs-fiat orders on the platform in the last 8 hours.
- morphit_native returns null for BTC (insufficient traders).
- Composite source falls through to Coingecko for BTC.
- BLURT can be Tier 3-derived from its own thicker data; BTC fails gracefully.
What this design DOESN'T solve (honest limitations)
- Per-asset denomination override — deliberately deferred (see "Per-asset denomination — NOT added" above). Revisit if a concrete use case appears.
- Klingex for non-BLURT — Klingex doesn't trade BTC/USDT or XMR/USDT at scale, so adding Klingex tier for BTC/XMR would just be a noisy fallback that always returns null. Skipped honestly.
- No new external sources for BTC/XMR — Coingecko + morphit_native is the chain. Adding e.g. Kraken or Binance fetchers would broaden the external set but adds maintenance burden + each new external dependency is a new privacy surface (per-instance HTTP calls to that exchange). Defer until concrete operator need.
- No UI consumer of BTC/XMR prices — cp130 ships the backend only. Future cp131+ orderbook USD-echo work would consume these prices. Ken explicitly noted item #6 (USD-equivalent orderbook display) is deferred indefinitely; that's fine — the backend stays useful even without UI consumption (operator inspection via receipt endpoint, possible API consumers).
- No new stablecoin-pegged-to-non-USD asset additions — EUR-pegged stablecoins (EURC/EURT/EURS) would unlock Tier 2 for EUR-denominated instances. Ken explicitly retired this item ("probably never"). Tier 2 on non-USD denominations continues to be effectively disabled in practice; documented honestly in ADR-0040.
Operator action required
-
None mandatory. Default
MORPHIT_INDEXER_PRICE_FEED_ENABLED=falseis unchanged; if you don't have the price feed enabled, nothing about cp130 affects you. -
Optional: if you enable the price feed AND want to override the per-asset static floors, set
MORPHIT_INDEXER_PRICE_FEED_BTC_STATIC_FLOORand/orMORPHIT_INDEXER_PRICE_FEED_XMR_STATIC_FLOOR. Defaults are USD-shaped (60_000 and 200); operators in non-USD denominations should override to their unit.
Privacy + decentralization posture
Per priorities #1 and #2:
- Same outbound calls as cp127 for BLURT plus 2 new outbound paths (Coingecko queries for BTC + XMR every 5 minutes when refresh fires). All over HTTPS, no API keys required for the free tier.
- No new federation requirement — cp130 doesn't depend on any peer behavior.
- No new operator-mandatory config — defaults work.
- No new central authority — each operator's per-asset price sources are independent.
Smokes
apps/indexer/scripts/multi-asset-factory-smoke.ts — 20 structural scenarios across 10 dimensions: (CP130-1) public surface area, (CP130-2) CP130_ASSET_DEFAULTS shape + launch set, (CP130-3) Klingex BLURT-only enforcement, (CP130-4) Coingecko coin-id correctness, (CP130-5) multi-asset map keying + instance distinctness, (CP130-6) backwards-compat wrapper, (CP130-7) per-asset static-floor wiring, (CP130-8) empty-db / morphit_native-disabled path, (CP130-9) denomination flowing through to all assets, (CP130-10) doc-comment design-pillars manifest.
Future work
- More external sources for BTC/XMR if operators report Coingecko-only being too narrow. Candidates: Kraken (public API, no key needed for ticker), CoinPaprika (free tier with higher limits than CG), Bitstamp (BTC primarily). Each adds privacy surface; pick based on operator demand.
- Per-asset denomination override if/when a concrete use case appears.
- Per-asset peer monitor thresholds — today all assets share the same
PEER_DISAGREEMENT_THRESHOLD=0.25. BTC's price is much less volatile per-day than BLURT's; a tighter threshold for BTC (say 10%) might catch finer manipulation. Defer to operator feedback.
Related code/docs
apps/indexer/src/indexer/price/factory.ts— generic asset factory + multi-asset map builderapps/indexer/src/indexer/price/coingeckoFetcher.ts— vsCurrency generalizationapps/indexer/src/config/index.ts— per-asset static-floor env vars + Config fieldsapps/indexer/src/main.ts— multi-asset boot + per-asset peer monitors + lifecycleapps/indexer/scripts/multi-asset-factory-smoke.ts— 20 structural scenariosapps/indexer/test/testutils/context.ts— fakeConfig defaults extendedops/env/indexer.env.example— documented new env vars