296 lines
14 KiB
Markdown
296 lines
14 KiB
Markdown
# ADR-0039 — Self-sovereign price derivation (morphit_native): tiered anchor architecture with cross-stablecoin depeg detection (cp127)
|
|
|
|
**Status:** Accepted (shipped 2026-05; pre-launch hardening campaign)
|
|
|
|
**Date:** 2026-05-23
|
|
**Deciders:** project maintainer (Ken)
|
|
**Related:** ADR-0011 (fee-collection design, where BTC/XMR fees came from); §F.11 BLURT-native fee refactor (which removed price-source dependency from fee verification); cp123-cp125 reputation hardening (whose Sybil-signal tables this design reuses).
|
|
|
|
## Context
|
|
|
|
Pre-cp127, Morphit's BLURT/USD price feed used a composite source
|
|
that tried external upstreams (Klingex → Coingecko) before falling
|
|
back to an operator-set static floor (default `$0.002`). This worked
|
|
during phases 3-4 when BLURT price was relatively flat, but had
|
|
several structural problems:
|
|
|
|
1. **External-source dependency**: every instance phoning home to
|
|
Klingex or Coingecko makes Morphit's price visibility a function
|
|
of external services that can be compromised, rate-limit us, or
|
|
shut down entirely. LocalBitcoins disappeared in 2023; LocalMonero
|
|
in 2024; Klingex is regionally hosted and subject to its own
|
|
regulatory environment. Depending on external sources for
|
|
anything "important" is the opposite of Morphit's decentralization
|
|
priority (priority #2 per the standing rules).
|
|
|
|
2. **Static fallback drifts from reality**: the operator-set
|
|
`$0.002` floor was a snapshot of BLURT price at one moment. Over
|
|
weeks-to-months it drifts from real market price, leaving the
|
|
USD echo (visible to users alongside listing-fee BLURT amounts)
|
|
meaningfully wrong when external sources are unavailable.
|
|
|
|
3. **No transparency**: users had to take the indexer's word for the
|
|
price. An operator could manipulate the static floor with no
|
|
external check; a compromised Klingex value would be served as
|
|
truth.
|
|
|
|
Ken's cp127 request: derive a self-sovereign price from on-platform
|
|
trade data, eventually reducing reliance on Klingex/Coingecko as
|
|
on-platform volume grows. Subsequent discussion expanded the scope
|
|
to handle scenarios where stablecoins themselves shut down, USD
|
|
itself gets replaced, or various adversarial conditions emerge.
|
|
|
|
## Decision
|
|
|
|
A new price-source upstream, `morphit_native`, that derives the
|
|
displayed asset/fiat rate from on-platform verified-fee orders.
|
|
Slotted between Coingecko and the static floor in the composite
|
|
chain (Klingex → Coingecko → **morphit_native** → static floor).
|
|
Opt-in via `MORPHIT_INDEXER_PRICE_FEED_NATIVE_ENABLED=true`.
|
|
|
|
### Tiered anchor architecture
|
|
|
|
`morphit_native` resolves a price through three tiers, in priority
|
|
order:
|
|
|
|
**Tier 1 — USD-fiat-direct anchor (PRIMARY)**. Orders where the
|
|
asset trades directly against the denomination fiat (typically USD)
|
|
via any payment method — bank transfer, cash in person, cash by
|
|
mail, Venmo, PayPal, etc. The fiat side is stated explicitly by
|
|
the trader. **Survives**: all stablecoin shutdowns, single-payment-
|
|
method failures, brand-new-fiat replacement of USD (via the
|
|
`denominationFiat` factory parameter).
|
|
|
|
**Tier 2 — Stablecoin-anchored supplement**. Orders where the
|
|
asset trades against stablecoins via `payment_methods` keys
|
|
(`pay_usdt`/`pay_usdc`/`pay_dai`). Each stablecoin's contribution
|
|
is gated by the cross-stablecoin depeg detector — only "pegged" or
|
|
"unknown" stablecoins count. **Survives**: single-stablecoin
|
|
shutdown (tier skipped entirely when <2 stablecoins available;
|
|
Tier 1 covers).
|
|
|
|
**Tier 3 — Hybrid combined pool**. When Tier 1 and Tier 2 each
|
|
independently fail to meet thresholds, combine into one pool.
|
|
Useful during bootstrap or post-shutdown degraded operation.
|
|
|
|
Each tier requires ≥3 distinct verified-fee traders with clean
|
|
Sybil-signal records. Each trader contributes their own median
|
|
order price (one vote per trader, regardless of order count). The
|
|
tier's final price is the median across trader medians.
|
|
|
|
### Cross-stablecoin depeg detection
|
|
|
|
A companion module (`stablecoinDepegDetector.ts`) computes
|
|
cross-stablecoin ratios from on-platform orders where stablecoins
|
|
trade against each other (USDT-vs-USDC, USDT-vs-DAI, USDC-vs-DAI,
|
|
etc.). If all ratios are near 1.0, all stablecoins are pegged.
|
|
If one stablecoin's ratios consistently diverge from 1.0 by more
|
|
than the threshold (default 3%), that stablecoin is "depegged" —
|
|
the median deviation across all pairs involving that coin is the
|
|
classifier.
|
|
|
|
The detector is **self-anchored** — it doesn't require external
|
|
knowledge of "what USD is worth." It only requires the assumption
|
|
that **the MAJORITY of stablecoins are correctly pegged at any
|
|
given moment**. This is a much weaker and more defensible
|
|
assumption than "USDT = $1 always."
|
|
|
|
When fewer than 2 stablecoins are available (one or zero
|
|
stablecoins remain), cross-ratio detection cannot run. All
|
|
stablecoins are reported as "unknown". Tier 2 is skipped in this
|
|
state; Tier 1 USD-direct covers the derivation.
|
|
|
|
### Hardened against the cp127 black-hat scenarios
|
|
|
|
The design explicitly defends against the conspiracy-theorist
|
|
attack scenarios surfaced in pre-implementation discussion:
|
|
|
|
| # | Attack | Defense (built into the code) |
|
|
|---|---|---|
|
|
| A | Sock-puppet whale | Proportional contribution cap via per-trader median (one trader = one vote) |
|
|
| B | Slow-drift attack ("frog in boiling water") | Long-term drift monitor with 7-day exponential baseline; alert on 25% sustained divergence |
|
|
| C | Klingex compromise undetected | Cross-source disagreement detector; opt-in priority flip env var |
|
|
| D | Post-and-cancel race | 10-minute order-age grace period before order qualifies; live status re-checked at query time |
|
|
| E | Operator-config envelope widening | Hardcoded outer plausibility bounds (`HARDCODED_OUTER_MIN_USD = 0.00001`, `HARDCODED_OUTER_MAX_USD = 10_000_000`) that operator config can only TIGHTEN, never widen |
|
|
| F | Cross-instance federation manipulation | Peer disagreement detector — implemented cp129 (ADR-0041); surfaces on `/v1/health` as of cp233 |
|
|
| G | Patient sock-puppet evading Sybil filters | Price-derivation receipt endpoint `/v1/price/morphit-native/receipt` for after-the-fact forensics |
|
|
| H | Downstream oracle abuse | NOT-AN-ORACLE warning in receipt payload + listing-fee payload + ADR + FAQ |
|
|
|
|
Additional structural defenses:
|
|
|
|
- **Spread-model echo defense**: only `kind: 'fixed'` orders count.
|
|
`kind: 'spread'` orders price against an external market
|
|
reference; including them would create a circular dependency.
|
|
- **Multi-method double-count defense**: each Tier 2 order
|
|
contributes via its FIRST eligible stablecoin only, regardless
|
|
of how many stablecoin payment methods it accepts.
|
|
- **Cold-start floor**: each contributing account must have ≥1
|
|
prior verified-fee completed order. Same protection as the
|
|
`is_new_trader` orderbook badge.
|
|
- **Sybil-table filters**: reuses cp123-cp125 reputation tables
|
|
(`suspicious_reciprocity`, `related_accounts`,
|
|
`one_way_pile_on`, `review_concentration`). Price manipulation
|
|
requires the same level of sophistication as reputation
|
|
manipulation — high bar by design.
|
|
|
|
### Resilience scenarios
|
|
|
|
**One stablecoin shuts down** → Tier 2 runs in degraded mode (cross-
|
|
ratio detector returns "unknown" for the lone remaining; we treat
|
|
"unknown" as eligible; effectively the single remaining stablecoin
|
|
is assumed to be $1). Tier 1 unaffected.
|
|
|
|
**Two or three stablecoins shut down** → Tier 2 skipped entirely
|
|
(`< NATIVE_MIN_STABLECOIN_COUNT_TIER2 = 2` eligible stablecoins).
|
|
Tier 1 still works. Tier 3 falls back to Tier 1 alone.
|
|
|
|
**Brand new world currency replaces USD** → operator changes the
|
|
`denominationFiat` factory parameter (currently hardcoded to 'USD'
|
|
in the cp127 factory call site, but the module is parameterized).
|
|
Same factory works with new fiat code; no schema change needed
|
|
since `orders.fiat_currency` is already a generic TEXT field.
|
|
|
|
**External sources go completely dark** → composite chain falls
|
|
through Klingex → Coingecko → morphit_native → static floor in
|
|
order. morphit_native picks up the slack when traders have generated
|
|
enough on-platform data.
|
|
|
|
### What this design DOESN'T solve (honest limitations)
|
|
|
|
- **Regulatory capture of all operators in a jurisdiction** —
|
|
federation-level structural limit; users in affected jurisdictions
|
|
use cross-jurisdiction instances via Tor/I2P/Lokinet.
|
|
- **State-actor forced account filtering** — operators can locally
|
|
filter accounts; users compare across instances per
|
|
`OPERATOR-TRUST-DESIGN.md`.
|
|
- **51%-of-legitimate-trade-volume by a single entity** — if one
|
|
entity legitimately runs >50% of volume, they ARE the market by
|
|
definition. Not a bug.
|
|
- **Patient sock-puppet long-game (months of building reputation
|
|
before activation)** — the cp123-cp125 Sybil filters catch
|
|
concentration patterns at intake, but a patient diversified
|
|
attacker can evade. Defense G (receipt endpoint) makes
|
|
post-hoc forensics easy; anticipation is the deterrent.
|
|
- **Klingex-Coingecko aggregation overlap** — Coingecko aggregates
|
|
many sources including possibly Klingex. Two-source check might
|
|
effectively be one source displayed twice. Documented honestly;
|
|
no clean fix.
|
|
- **CBDC stealth via "USD" payment methods** — out of scope for
|
|
the price-source design but relevant for broader privacy.
|
|
|
|
## Operator action required
|
|
|
|
- **None mandatory.** Schema migration adds one new table
|
|
(`price_drift_baseline`) idempotently via `CREATE TABLE IF NOT
|
|
EXISTS`. Existing instances pick this up on next indexer restart.
|
|
- **Optional**: operators can enable the native fetcher via
|
|
`MORPHIT_INDEXER_PRICE_FEED_NATIVE_ENABLED=true` when they trust
|
|
their platform's trade volume to support self-sovereign pricing.
|
|
- **Optional**: operators with mature data can flip
|
|
`MORPHIT_INDEXER_PRICE_PREFER_NATIVE_WHEN_DISAGREEING=true` to
|
|
prefer the native price over external sources during sustained
|
|
disagreement.
|
|
|
|
## Performance posture
|
|
|
|
- **Per-refresh cost**: 3-6 SQL queries (one per tier + depeg
|
|
detector cross-pair queries). Each query is bounded by the
|
|
8-hour window + Sybil-table filters. Cheap relative to the
|
|
composite source's 5-min refresh cadence.
|
|
- **Drift baseline update**: one INSERT-or-UPDATE per refresh per
|
|
(asset, fiat) pair. Trivial.
|
|
- **Receipt endpoint**: O(N) in qualifying orders + cross-pair
|
|
data. 60s ETag caching mitigates repeat-call cost.
|
|
|
|
## Privacy posture
|
|
|
|
Per priority #1:
|
|
|
|
1. **No new on-chain data.** All changes operate on already-chain-
|
|
anchored orders + locally-derived signal tables. No new custom
|
|
JSON op types.
|
|
2. **Receipt endpoint reveals which accounts contributed to a price**
|
|
— but this is already publicly inferable from the orderbook. No
|
|
new privacy leak.
|
|
3. **Cross-stablecoin depeg detector reads existing tables only.**
|
|
No new chain-data inputs.
|
|
|
|
## Decentralization posture
|
|
|
|
Per priority #2:
|
|
|
|
- **Per-instance signal-table + baseline state** is the same posture
|
|
as the pre-existing Signals A-D. Two indexers can show slightly-
|
|
different prices based on their local data. The disagreement
|
|
monitor + receipt endpoint surface this disagreement explicitly.
|
|
- **No new federation-wide constants** beyond the hardcoded outer
|
|
envelope (which is a SAFETY floor, not a coordination point).
|
|
- **No new operator-mandatory config**. Existing operators
|
|
upgrade and the migration applies automatically.
|
|
|
|
## Future work
|
|
|
|
Tracked as REVISIT items for cp128+:
|
|
|
|
- ~~**Defense F (cross-instance peer disagreement detector)**~~ —
|
|
**Done, cp129 (ADR-0041).** Samples peer instances' receipts and
|
|
alerts on sustained cross-instance divergence.
|
|
- **Per-asset wiring for BTC/USD, XMR/USD**: the factory is generic;
|
|
wire additional asset/fiat instances when needed for the
|
|
listing-fee USD-echo extension to BTC/XMR fees.
|
|
- **USD-equivalent display in orderbook**: per-asset native price
|
|
surfaces in the orderbook UI for cross-order comparison.
|
|
- **denominationFiat parameterization at the call-site level**:
|
|
today the factory hardcodes `denominationFiat: 'USD'`. Wire it
|
|
to an operator config field so non-USD operator instances can use
|
|
morphit_native too.
|
|
|
|
## Update — cp233 (runtime wiring of B + C; `/v1/health` surfacing for B + C + F)
|
|
|
|
Defenses **B** (slow-drift) and **C** (cross-source disagreement)
|
|
were *built* in cp127 (modules + contract smokes) but were not yet
|
|
invoked in the live price-refresh path — only **F** (cp129) was
|
|
runtime-wired. cp233 closes that gap and makes all three observable:
|
|
|
|
- **B** runs on every successful price refresh
|
|
(`compositeSource.refreshOnce` → `updateAndCheckDrift`, per-asset),
|
|
persisting its moving baseline to the `price_drift_baseline`
|
|
table. Peer-independent.
|
|
- **C** runs as a per-asset background monitor
|
|
(`startDisagreementMonitor`) that cross-checks the published
|
|
external price against a freshly-derived `morphit_native` price
|
|
each cycle. In-process, no table. It treats the published price
|
|
as an external reference **only** when it came from a real
|
|
external market source (klingex/coingecko) — never the static
|
|
floor or the native fallback — so it cannot false-alarm while
|
|
external sources are briefly unreachable. On by default whenever
|
|
native pricing is enabled. Peer-independent.
|
|
- **All three (B, C, F)** now surface on `/v1/health?verbose=1`
|
|
under `price.drift`, `price.disagreement`, and `price.peer`, and
|
|
each log-alerts on a sustained breach.
|
|
- The wiring is pinned by `price-source-hardening-smoke.ts`
|
|
(`BW-*`, `CW-*`, `FS-*` scenarios) so a future refactor cannot
|
|
silently unwire any of it.
|
|
|
|
Net effect: a lone early-beta instance with no federation peers
|
|
still gets slow-drift (B) and source-disagreement (C) protection; F
|
|
additionally activates once ≥3 peers are reachable.
|
|
|
|
## Related
|
|
|
|
- `apps/indexer/src/indexer/price/morphitNativeFetcher.ts` — generic
|
|
factory + tiered anchor implementation
|
|
- `apps/indexer/src/indexer/price/stablecoinDepegDetector.ts` —
|
|
cross-stablecoin ratio analysis
|
|
- `apps/indexer/src/indexer/price/driftMonitor.ts` — long-term drift
|
|
sanity check (defense B)
|
|
- `apps/indexer/src/indexer/price/disagreementMonitor.ts` —
|
|
cross-source disagreement detector (defense C)
|
|
- `apps/indexer/src/api/priceReceipt.ts` — `/v1/price/morphit-native/receipt`
|
|
endpoint (defense G)
|
|
- `apps/indexer/scripts/stablecoin-depeg-detector-smoke.ts` —
|
|
6 structural scenarios
|
|
- `apps/indexer/scripts/morphit-native-fetcher-smoke.ts` — 10
|
|
structural scenarios (one per defense + contract checks)
|
|
- `apps/indexer/scripts/price-source-hardening-smoke.ts` — 14
|
|
scenarios across receipt + drift + disagreement + factory wiring
|