291 lines
12 KiB
Markdown
291 lines
12 KiB
Markdown
# ADR-0041 — Cross-instance peer price disagreement detector (cp129 Defense F)
|
|
|
|
**Status:** Accepted (shipped 2026-05; pre-launch hardening campaign)
|
|
|
|
**Date:** 2026-05-23
|
|
**Deciders:** project maintainer (Ken)
|
|
**Related:** ADR-0039 (self-sovereign pricing — designed 8 black-hat defenses A-H; Defense F was deferred from cp127 to "future work"; cp129 implements it). ADR-0040 (denomination configurability — the same-denomination filter in Defense F builds on this).
|
|
|
|
## Context
|
|
|
|
ADR-0039 designed the `morphit_native` price fetcher with eight
|
|
specific manipulation defenses (A through H). Seven shipped in
|
|
cp127. Defense F — **cross-instance peer disagreement detector**
|
|
— was deferred because it required federation-aware code that
|
|
ADR-0039's scope didn't cover.
|
|
|
|
The threat Defense F addresses: **what if THIS indexer is the one
|
|
being manipulated?**
|
|
|
|
All other cp127 defenses assume the indexer's own code paths are
|
|
correct and the manipulation happens at the trader / data-source
|
|
level (sock-puppet whales, slow-drift attacks, post-and-cancel
|
|
races, etc.). But what if an operator is compromised — pressured
|
|
by a regulator, bribed, captured — and starts patching their
|
|
indexer to report a fake derived price? None of the in-indexer
|
|
defenses can catch this; the indexer IS the source of truth for
|
|
its own derivation.
|
|
|
|
Cross-instance peer disagreement DOES catch it. If my indexer
|
|
reports BLURT/USD = $0.005 while every other indexer reports
|
|
$0.002, that disagreement is suspicious by construction. An alert
|
|
surfaces; the operator can investigate; users get a warning surface
|
|
on `/v1/health`.
|
|
|
|
## Decision
|
|
|
|
Ship a new background module — `peerPriceMonitor.ts` — that
|
|
periodically samples peer instances' `/v1/price/morphit-native/receipt`
|
|
endpoint, stores observations to a dedicated DB table, computes
|
|
the peer median over a recent window, and compares against the
|
|
indexer's own derived price. Sustained disagreement above
|
|
threshold fires an alert.
|
|
|
|
### Module
|
|
|
|
`apps/indexer/src/indexer/price/peerPriceMonitor.ts`
|
|
(~480 lines including doc-comment). Exports:
|
|
|
|
- `runPeerPriceSampleCycle(cfg, now?)` — one sample cycle (query
|
|
peers + store + compare + maybe alert). Returns a
|
|
`PeerSampleCycleResult` for testability.
|
|
- `startPeerPriceMonitor(cfg, intervalMinutes?)` — schedules
|
|
recurring cycles via `setInterval`; returns a stop function.
|
|
- `fetchPeerReceipt(origin, asset, denomination_fiat, timeoutMs?)`
|
|
— single-peer HTTP query with short timeout + graceful failure.
|
|
- `median(values)` — pure-function median (sort-invariant,
|
|
resists single outlier).
|
|
- `disagreementExceedsThreshold(myPrice, peerMedian, threshold)`
|
|
— pure-function comparison.
|
|
- `shouldFireAlert(aboveSince, now, lastAlert, sustained, cooldown)`
|
|
— pure-function alert decision (testable without wall-clock).
|
|
- `pruneOldObservations(db, now?, retentionDays?)` — TTL cleanup.
|
|
|
|
### Storage
|
|
|
|
New schema-v36 table `price_peer_observations`:
|
|
|
|
| Column | Type | Notes |
|
|
|---|---|---|
|
|
| peer_origin | TEXT | URL of the peer instance |
|
|
| asset | TEXT | which asset's price (BLURT today; BTC/XMR future) |
|
|
| denomination_fiat | TEXT | which fiat (USD today; EUR/XDR/XAU future per cp128) |
|
|
| observed_price | NUMERIC(38,18) | peer's reported derived_price |
|
|
| observed_at | TIMESTAMPTZ | when we recorded it |
|
|
| source_native | TEXT | 'morphit_native' / 'unknown' — only morphit_native rows are used in the median |
|
|
|
|
Indexed on `(asset, denomination_fiat, observed_at DESC)` for
|
|
fast median-window queries. TTL'd to 7 days; cleanup runs every
|
|
cycle.
|
|
|
|
### Configuration
|
|
|
|
Two new env vars (both with sane defaults):
|
|
|
|
| Env var | Default | Purpose |
|
|
|---|---|---|
|
|
| `MORPHIT_INDEXER_PEER_PRICE_MONITOR_ENABLED` | `false` | Opt-in master switch. Off for new instances with no peers. |
|
|
| `MORPHIT_INDEXER_PEER_PRICE_SAMPLE_INTERVAL_MINUTES` | `30` | How often to sample peers. ≥5 min recommended. |
|
|
|
|
Numeric thresholds (built into the code, not env-tunable):
|
|
|
|
| Constant | Default | Purpose |
|
|
|---|---|---|
|
|
| `PEER_DISAGREEMENT_THRESHOLD` | 0.25 | 25% deviation triggers the over-threshold flag |
|
|
| `PEER_DISAGREEMENT_WINDOW_HOURS` | 4 | Time window for median computation |
|
|
| `PEER_DISAGREEMENT_SUSTAINED_HOURS` | 4 | Hours over-threshold before alert fires |
|
|
| `PEER_ALERT_COOLDOWN_HOURS` | 24 | Hours between alerts (anti-spam) |
|
|
| `PEER_MIN_OBSERVATIONS` | 3 | Below this, comparison is silent |
|
|
| `PEER_OBSERVATION_RETENTION_DAYS` | 7 | TTL for stored observations |
|
|
| `PEER_FETCH_TIMEOUT_MS` | 10000 | Per-peer HTTP timeout |
|
|
|
|
These are constants to keep the trust model legible. An
|
|
operator who wants to tune them needs to fork the code, which is
|
|
a feature: peer-disagreement thresholds are a federation-level
|
|
concern that shouldn't drift per-instance.
|
|
|
|
### Wiring
|
|
|
|
- `apps/indexer/src/main.ts` starts the monitor on boot when
|
|
enabled. Stop function registered for graceful shutdown.
|
|
- Federation discovery uses the existing `known_instances` table,
|
|
filtered to `last_probe_status IN ('good', 'quiet')` — peers
|
|
the federation prober already vetted.
|
|
|
|
### Why median, not mean
|
|
|
|
A single malicious peer feeding a wildly wrong price (e.g.
|
|
$1 BLURT/USD instead of $0.002) shouldn't be able to move the
|
|
result. Mean is shifted by extreme values; median is not.
|
|
|
|
This means an attacker needs to compromise **a majority of peers
|
|
in the comparison window** to manipulate the median — not just
|
|
one or two. Combined with the federation prober's existing
|
|
operator chain-registration checks (which require costly setup
|
|
to get a peer into the directory at all), Sybil-ing the peer set
|
|
becomes expensive enough to deter the attack class.
|
|
|
|
### Why ≥3 peers minimum
|
|
|
|
Below 3 peers, the median is trivially moved by any single outlier
|
|
(with 2 peers, median = average of the two; with 1, median = that
|
|
value). At 3 peers, a single outlier is the min or max and gets
|
|
filtered out by the median.
|
|
|
|
Operators with fewer than 3 reachable peers see the monitor
|
|
degrade gracefully — no alert is ever fired. This is a feature:
|
|
no signal is better than a misleading signal. An operator running
|
|
on a brand-new federation should leave the monitor off until they
|
|
have enough peers.
|
|
|
|
### Same-denomination filter
|
|
|
|
Cp128 made denomination configurable per-operator. A USD-
|
|
denominated indexer cannot meaningfully compare its BLURT/USD
|
|
price to a EUR-denominated peer's BLURT/EUR price without a
|
|
USD/EUR oracle, which would defeat the self-sovereign premise.
|
|
|
|
So the monitor filters peers to those reporting the SAME
|
|
denomination_fiat. A USD-denominated indexer in a mostly-EUR
|
|
federation will see few comparable peers and skip the comparison
|
|
entirely (which is correct).
|
|
|
|
### Same-source filter
|
|
|
|
The receipt endpoint can return prices from various sources
|
|
(external feeds, native fetcher, static fallback). Comparing my
|
|
native-derived price to a peer's external-feed-derived price is
|
|
apples-to-oranges. So the median only includes peer observations
|
|
where `source = 'morphit_native'` — apples to apples.
|
|
|
|
Peers running older versions that don't include a `source` field
|
|
get tagged `'unknown'` and excluded from the median.
|
|
|
|
## Resilience scenarios
|
|
|
|
### Single peer compromised, reporting wildly wrong price
|
|
- Their observation is one data point among ≥3.
|
|
- Median is unmoved.
|
|
- No alert. Defense works as designed (resistant to outliers).
|
|
|
|
### Half the peers compromised, all reporting same wrong price
|
|
- The compromised group sets the median.
|
|
- I'd appear to be the outlier from MY perspective; my alert
|
|
would fire on innocent me.
|
|
- **This is the inherent limit** of any majority-based defense.
|
|
Same blind spot as cp127's "consensus from compromised
|
|
sources." Cross-federation peer-Sybil is expensive but not
|
|
impossible.
|
|
- Mitigation: the federation prober's chain-registration check
|
|
(each peer must have a chain-broadcasted `morphit_operator_register_v1`
|
|
op) raises the cost.
|
|
|
|
### My indexer compromised, reporting wildly wrong price
|
|
- Healthy peers' median is correct.
|
|
- I'm the outlier.
|
|
- Alert fires within 4 hours of sustained disagreement.
|
|
- **This is the primary attack class Defense F catches.**
|
|
|
|
### My indexer + few peers compromised, but most peers healthy
|
|
- The median is mostly determined by the healthy majority.
|
|
- I'm flagged as the outlier (correct).
|
|
- Alert fires. Defense works.
|
|
|
|
### Genuine market disagreement (e.g. flash crash)
|
|
- Different peers may have updated their native price at
|
|
different cadences.
|
|
- Window is 4 hours — short-term volatility is averaged in.
|
|
- Sustained-disagreement window adds another 4 hours of dwell.
|
|
- For a true 8-hour disagreement to fire, the market must
|
|
genuinely be reporting a divergent price. Reasonable.
|
|
|
|
### Geographic isolation (peer-poor federation)
|
|
- <3 peers reachable → monitor degrades silently.
|
|
- No false alarms. Operator can investigate manually via
|
|
`/v1/instances` and `/v1/price/morphit-native/receipt`.
|
|
|
|
### All peers offline (network partition)
|
|
- 0 observations → silent.
|
|
- Operator sees `peersQueried: N, observationsInWindow: 0` in
|
|
debug logs.
|
|
|
|
## What this design DOESN'T solve (honest limitations)
|
|
|
|
- **All-federation collusion / shared poisoned source.** If
|
|
every operator is reporting the same wrong price (e.g. because
|
|
they all rely on the same compromised external data), no
|
|
cross-instance comparison can detect it. Same limitation as
|
|
ADR-0039's Defense C documented.
|
|
- **Geographic-isolation false negatives.** An operator in a
|
|
region where most peers are unreachable just sees fewer peer
|
|
observations and may degrade to silent. Mitigation: monitor
|
|
multiple federation peers across Tor + I2P + clearnet for
|
|
reachability redundancy.
|
|
- **Per-peer trust weighting.** All peers count equally in the
|
|
median. A future enhancement could weight peers by their
|
|
age, trade volume, or federation-prober "goodness." Cp129
|
|
ships the simpler equal-weight approach; weighted-median is a
|
|
REVISIT item.
|
|
|
|
## Operator action required
|
|
|
|
- **None mandatory.** Default `MORPHIT_INDEXER_PEER_PRICE_MONITOR_ENABLED=false`
|
|
is safe for any instance.
|
|
|
|
- **Optional for operators with federation peers:** set the env
|
|
var to `true` after confirming you have ≥3 reachable peers via
|
|
`/v1/instances`. No code change needed.
|
|
|
|
## Privacy + decentralization posture
|
|
|
|
Per priorities #1 and #2:
|
|
|
|
- **Peer queries are over normal HTTPS** — same as the federation
|
|
prober's existing queries. No new privacy surface.
|
|
- **Observations live in this indexer's own DB** — never federated,
|
|
never on-chain, never logged externally.
|
|
- **No new central authority.** Each indexer makes its own
|
|
comparison; alerts are local-only (operator logs + own
|
|
`/v1/health`).
|
|
- **No new mandatory peer.** Disable the env var and the monitor
|
|
doesn't run at all.
|
|
|
|
## Smokes
|
|
|
|
`apps/indexer/scripts/peer-price-monitor-smoke.ts` — 28 structural
|
|
scenarios covering: public surface area, sane numeric defaults,
|
|
median pure-function correctness (including outlier-resistance),
|
|
disagreementExceedsThreshold correctness (both directions, edge
|
|
cases), shouldFireAlert correctness (sustained, cooldown, edge
|
|
cases), and doc-comment defense manifest.
|
|
|
|
## Future work
|
|
|
|
- **Weighted peer median** (REVISIT cp130+) — weight peers by
|
|
federation-prober goodness score, age, or trade volume.
|
|
- ~~**/v1/health surface** for the peer-disagreement state~~ —
|
|
**Done, cp233.** The latest peer-comparison cycle now surfaces on
|
|
`/v1/health?verbose=1` under `price.peer` (peers queried, peer
|
|
median vs own price, deviation, above-threshold flag, alert),
|
|
captured via an optional `onResult` callback on
|
|
`startPeerPriceMonitor` so F's core logic was untouched. Surfaced
|
|
alongside the cp233-wired defenses B (`price.drift`) and C
|
|
(`price.disagreement`) — see ADR-0039's cp233 update. (The cp129
|
|
`schema.sql` comment claimed F already surfaced here; that was
|
|
aspirational until cp233 — this future-work item was the accurate
|
|
record.)
|
|
- ~~**Cross-asset extension**~~ — **Done, cp130.** The monitor is
|
|
started per-asset for every configured (asset, denomination_fiat)
|
|
pair (BLURT/BTC/XMR); cp233 captures each asset's latest result.
|
|
|
|
## Related code/docs
|
|
|
|
- `apps/indexer/src/indexer/price/peerPriceMonitor.ts` — module
|
|
- `apps/indexer/src/db/schema.sql` — schema-v36 table
|
|
- `apps/indexer/src/config/index.ts` — env vars + Config fields
|
|
- `apps/indexer/src/main.ts` — startup + graceful shutdown wiring
|
|
- `apps/indexer/src/api/health.ts` — `/v1/health` `price.peer` surface (cp233)
|
|
- `apps/indexer/scripts/peer-price-monitor-smoke.ts` — 39 scenarios
|
|
- `apps/indexer/scripts/price-source-hardening-smoke.ts` — B/C/F wiring + surface scenarios (`BW-*`, `CW-*`, `FS-*`, cp233)
|
|
- `ops/env/indexer.env.example` — documented env vars
|
|
- `scripts/run-smokes.sh` — smoke registered in runner
|