157 lines
13 KiB
Markdown
157 lines
13 KiB
Markdown
# 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 taking `AssetPriceSourceOptions` (asset, coingeckoCoinId, enableKlingex, staticFloor)
|
|
- `CP130_ASSET_DEFAULTS` — per-asset defaults for the cp130 launch set (BLURT, BTC, XMR)
|
|
- `createMultiAssetPriceSources(config, db?)` — returns `Map<string, BlurtPriceSource>` keyed by asset ticker
|
|
- `createPriceSource(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: string` field in `CoingeckoConfig` — 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=EUR` gets 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:
|
|
|
|
```typescript
|
|
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=eur` for 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=false` is 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_FLOOR` and/or `MORPHIT_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 builder
|
|
- `apps/indexer/src/indexer/price/coingeckoFetcher.ts` — vsCurrency generalization
|
|
- `apps/indexer/src/config/index.ts` — per-asset static-floor env vars + Config fields
|
|
- `apps/indexer/src/main.ts` — multi-asset boot + per-asset peer monitors + lifecycle
|
|
- `apps/indexer/scripts/multi-asset-factory-smoke.ts` — 20 structural scenarios
|
|
- `apps/indexer/test/testutils/context.ts` — fakeConfig defaults extended
|
|
- `ops/env/indexer.env.example` — documented new env vars
|