morphit/apps/indexer/scripts/price-source-hardening-smoke.ts

625 lines
21 KiB
TypeScript

#!/usr/bin/env tsx
/**
* apps/indexer/scripts/price-source-hardening-smoke.ts
*
* Combined structural smokes for the three cp127 hardening modules:
*
* - Price-receipt endpoint shape (defense G)
* - Drift monitor (defense B)
* - Disagreement monitor (defense C)
*
* Each subsection pins the contract so silent drift in any of these
* modules trips a smoke.
*/
import {
NOT_AN_ORACLE_WARNING
} from '../src/api/priceReceipt';
import {
DRIFT_HALF_LIFE_HOURS,
DRIFT_ALERT_THRESHOLD,
DRIFT_ALERT_SUSTAINED_HOURS
} from '../src/indexer/price/driftMonitor';
import {
DisagreementMonitor,
DISAGREEMENT_THRESHOLD,
DISAGREEMENT_ALERT_SUSTAINED_HOURS,
startDisagreementMonitor,
runDisagreementCheckCycle
} from '../src/indexer/price/disagreementMonitor';
import { CompositeCachedPriceSource } from '../src/indexer/price/compositeSource';
import type { BlurtPriceSource } from '../src/indexer/price/source';
import type { Database } from '../src/db/pool';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
let failed = 0;
let passed = 0;
function pass(name: string): void {
console.log(`${name}`);
passed++;
}
function fail(name: string, detail: string): void {
console.error(`${name}`);
console.error(` ${detail}`);
failed++;
}
console.log('\n── price-source-hardening invariants smoke (cp127) ───\n');
// ── Receipt endpoint ─────────────────────────────────────────────
// PR-1 NOT-AN-ORACLE warning text completeness
{
const required = [
'NOT-AN-ORACLE',
'oracle',
'lending',
'against',
'ADR-0039'
];
const missing = required.filter((w) => !NOT_AN_ORACLE_WARNING.toLowerCase().includes(w.toLowerCase()));
if (missing.length === 0) {
pass('PR-1 NOT-AN-ORACLE warning text names oracle abuse + lending protocols + ADR-0039');
} else {
fail('PR-1', `missing keywords: ${missing.join(', ')}`);
}
}
// PR-2 warning string is long enough to be visibly serious (not "ok, ok, NOT_AN_ORACLE")
{
if (NOT_AN_ORACLE_WARNING.length >= 200) {
pass(`PR-2 NOT-AN-ORACLE warning is ${NOT_AN_ORACLE_WARNING.length} chars — long enough to be visibly serious`);
} else {
fail('PR-2', `warning too short: ${NOT_AN_ORACLE_WARNING.length} chars`);
}
}
// PR-3 listing-fee body includes the price_warning field when USD echo is present
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'api', 'listingFeeBody.ts'),
'utf-8'
);
if (src.includes('price_warning') && src.includes('NOT-AN-ORACLE')) {
pass('PR-3 listing-fee body includes NOT-AN-ORACLE price_warning field (defense H)');
} else {
fail('PR-3', 'listing-fee body missing price_warning field or NOT-AN-ORACLE text');
}
}
// ── Drift monitor ────────────────────────────────────────────────
// DR-1 sane defaults
{
const sane =
DRIFT_HALF_LIFE_HOURS >= 1 &&
DRIFT_HALF_LIFE_HOURS <= 168 &&
DRIFT_ALERT_THRESHOLD > 0 &&
DRIFT_ALERT_THRESHOLD < 1 &&
DRIFT_ALERT_SUSTAINED_HOURS >= 1;
if (sane) {
pass(
`DR-1 drift defaults sane: half-life=${DRIFT_HALF_LIFE_HOURS}h, alert=${DRIFT_ALERT_THRESHOLD * 100}%, sustained=${DRIFT_ALERT_SUSTAINED_HOURS}h`
);
} else {
fail('DR-1', 'drift defaults outside sane range');
}
}
// DR-2 schema migration present
{
const schemaSrc = readFileSync(
resolve(__dirname, '..', 'src', 'db', 'schema.sql'),
'utf-8'
);
if (
schemaSrc.includes('CREATE TABLE IF NOT EXISTS price_drift_baseline') &&
schemaSrc.includes('baseline_price') &&
schemaSrc.includes('above_threshold_since')
) {
pass('DR-2 price_drift_baseline table migration present in canonical schema');
} else {
fail('DR-2', 'schema migration for price_drift_baseline missing or incomplete');
}
}
// ── Disagreement monitor ─────────────────────────────────────────
// DM-1 sane defaults
{
const sane =
DISAGREEMENT_THRESHOLD > 0 &&
DISAGREEMENT_THRESHOLD < 1 &&
DISAGREEMENT_ALERT_SUSTAINED_HOURS >= 1;
if (sane)
pass(
`DM-1 disagreement defaults sane: threshold=${DISAGREEMENT_THRESHOLD * 100}%, sustained=${DISAGREEMENT_ALERT_SUSTAINED_HOURS}h`
);
else fail('DM-1', 'disagreement defaults outside sane range');
}
// DM-2 monitor behavior: matching prices → inactive
{
const m = new DisagreementMonitor('BLURT', 'USD');
const r = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.00205, // 2.5% — under threshold
now: new Date()
});
if (r.active === false && r.alert_fired === false) {
pass('DM-2 matching prices (small deviation) → monitor inactive');
} else {
fail('DM-2', `expected inactive, got: ${JSON.stringify(r)}`);
}
}
// DM-3 monitor behavior: divergent prices → active
{
const m = new DisagreementMonitor('BLURT', 'USD');
const r = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003, // 50% — over threshold
now: new Date()
});
if (r.active === true && r.deviation !== null && Math.abs(r.deviation) > 0.25) {
pass('DM-3 divergent prices (50% deviation) → monitor active');
} else {
fail('DM-3', `expected active, got: ${JSON.stringify(r)}`);
}
}
// DM-4 monitor behavior: alert requires sustained divergence
{
const m = new DisagreementMonitor('BLURT', 'USD', 0.25, 4);
const t0 = new Date('2026-05-23T00:00:00Z');
const t1 = new Date('2026-05-23T01:00:00Z'); // 1h later, under sustained
const t5 = new Date('2026-05-23T05:00:00Z'); // 5h later, over sustained
const r1 = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003,
now: t0
});
const r2 = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003,
now: t1
});
const r3 = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003,
now: t5
});
if (
r1.alert_fired === false &&
r2.alert_fired === false &&
r3.alert_fired === true
) {
pass('DM-4 alert fires only after sustained divergence (≥ sustainedHours)');
} else {
fail('DM-4', `alert sequence wrong: ${r1.alert_fired},${r2.alert_fired},${r3.alert_fired}`);
}
}
// DM-5 alert rate-limited to once per 24h
{
const m = new DisagreementMonitor('BLURT', 'USD', 0.25, 4);
const t0 = new Date('2026-05-23T00:00:00Z');
const t5 = new Date('2026-05-23T05:00:00Z'); // 5h after start, alert fires
const t10 = new Date('2026-05-23T10:00:00Z'); // 5h after that, should NOT re-alert
const t30 = new Date('2026-05-24T06:00:00Z'); // 30h after start, can re-alert
m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003,
now: t0
});
const r1 = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003,
now: t5
});
const r2 = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003,
now: t10
});
const r3 = m.check({
externalPrice: 0.002,
externalSourceName: 'coingecko',
nativePrice: 0.003,
now: t30
});
if (
r1.alert_fired === true &&
r2.alert_fired === false &&
r3.alert_fired === true
) {
pass('DM-5 alert rate-limited to once per 24h per (asset, fiat)');
} else {
fail(
'DM-5',
`alert sequence wrong: ${r1.alert_fired},${r2.alert_fired},${r3.alert_fired}`
);
}
}
// DM-6 null inputs → inactive (no panic on missing data)
{
const m = new DisagreementMonitor('BLURT', 'USD');
const r = m.check({
externalPrice: null,
externalSourceName: null,
nativePrice: 0.002,
now: new Date()
});
if (r.active === false && r.deviation === null) {
pass('DM-6 null external price → inactive (no false alarm)');
} else {
fail('DM-6', `expected inactive, got: ${JSON.stringify(r)}`);
}
}
// ── Factory + composite wiring ───────────────────────────────────
// FW-1 factory wires morphit_native as the FALLBACK tier. cp372:
// native is no longer blended into the external priority chain — it's
// the fallback tier consulted only when all external sources are
// down, which keeps defense C's external-vs-native cross-check
// meaningful (native is what's checked against, not averaged in).
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'indexer', 'price', 'factory.ts'),
'utf-8'
);
const hasNativeWire = src.includes('morphit_native') && src.includes('createMorphitNativeFetcher');
const isFallbackTier = src.includes('fallbackUpstreams');
const lowered = src.toLowerCase();
const fallbackComment =
lowered.includes('not blended') ||
(lowered.includes('fallback tier') && lowered.includes('native'));
if (hasNativeWire && isFallbackTier && fallbackComment) {
pass('FW-1 factory wires morphit_native as the fallback tier (not blended into the external average)');
} else {
fail(
'FW-1',
`native wired: ${hasNativeWire}; fallback tier: ${isFallbackTier}; comment: ${fallbackComment}`
);
}
}
// FW-2 config exposes the cp127 env vars
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'config', 'index.ts'),
'utf-8'
);
const required = [
'MORPHIT_INDEXER_PRICE_FEED_NATIVE_ENABLED',
'MORPHIT_INDEXER_PRICE_PREFER_NATIVE_WHEN_DISAGREEING',
'MORPHIT_INDEXER_PRICE_FEED_STABLECOIN_KEYS',
'MORPHIT_INDEXER_PRICE_FEED_NATIVE_PLAUSIBLE_MIN',
'MORPHIT_INDEXER_PRICE_FEED_NATIVE_PLAUSIBLE_MAX'
];
const missing = required.filter((k) => !src.includes(k));
if (missing.length === 0) {
pass('FW-2 config exposes all 5 cp127 env vars');
} else {
fail('FW-2', `missing env vars: ${missing.join(', ')}`);
}
}
// FW-3 priceReceipt route mounted in main.ts
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'main.ts'),
'utf-8'
);
if (src.includes('priceReceiptRoute') && src.includes("/v1/price")) {
pass('FW-3 priceReceipt route mounted at /v1/price in main.ts');
} else {
fail('FW-3', 'priceReceipt route not properly wired in main.ts');
}
}
// ─────────────────────────────────────────────────────────────────
// cp233 — defense B + C RUNTIME WIRING. The contract checks above
// prove the modules behave; these prove they are actually invoked in
// the refresh path / main.ts / health, so a future refactor cannot
// silently unwire them (precisely the failure mode these exist to
// prevent).
// ─────────────────────────────────────────────────────────────────
// BW-1 — compositeSource invokes the drift check after a commit and
// exposes driftStatus().
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'indexer', 'price', 'compositeSource.ts'),
'utf-8'
);
if (
src.includes('updateAndCheckDrift') &&
src.includes('driftStatus()') &&
src.includes('this.lastDrift')
) {
pass('BW-1 compositeSource calls updateAndCheckDrift + exposes driftStatus()');
} else {
fail('BW-1', 'drift hook or driftStatus() accessor missing from compositeSource');
}
}
// BW-2 — factory passes db + asset + denominationFiat to the source
// (without all three, the drift hook is dormant).
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'indexer', 'price', 'factory.ts'),
'utf-8'
);
const m = src.match(/new CompositeCachedPriceSource\(\{[\s\S]*?\}\)/);
const ctor = m ? m[0] : '';
if (ctor.includes('db') && ctor.includes('asset:') && ctor.includes('denominationFiat:')) {
pass('BW-2 factory passes db + asset + denominationFiat into CompositeCachedPriceSource');
} else {
fail('BW-2', 'factory no longer wires db/asset/denominationFiat into the source');
}
}
// BW-3 — /v1/health surfaces the drift block.
{
const src = readFileSync(resolve(__dirname, '..', 'src', 'api', 'health.ts'), 'utf-8');
if (src.includes('driftStatus?.()') && src.includes('drift:')) {
pass('BW-3 /v1/health surfaces the drift block');
} else {
fail('BW-3', 'health.ts no longer surfaces drift');
}
}
// BW-RT — the drift check actually runs on a successful refresh when
// db/asset/fiat are wired, and is dormant (no db touch) when they are
// not. Catches the hook being removed or the guard inverting.
{
let queries = 0;
const fakeDb = {
query: async () => {
queries++;
return { rows: [] };
}
} as unknown as Database;
const wired = new CompositeCachedPriceSource({
upstreams: [{ name: 'coingecko', fetch: async () => 0.005 }],
staticFloor: 0.002,
refreshIntervalMs: 60_000,
setInterval: (() => 0) as unknown as typeof setInterval,
clearInterval: (() => {}) as unknown as typeof clearInterval,
db: fakeDb,
asset: 'BLURT',
denominationFiat: 'USD'
});
await wired.refreshOnce();
const wiredOk = queries >= 1 && wired.driftStatus() !== null;
let queries2 = 0;
const fakeDb2 = {
query: async () => {
queries2++;
return { rows: [] };
}
} as unknown as Database;
void fakeDb2; // referenced for symmetry; unwired source gets no db
const unwired = new CompositeCachedPriceSource({
upstreams: [{ name: 'coingecko', fetch: async () => 0.005 }],
staticFloor: 0.002,
refreshIntervalMs: 60_000,
setInterval: (() => 0) as unknown as typeof setInterval,
clearInterval: (() => {}) as unknown as typeof clearInterval
// deliberately no db/asset/denominationFiat
});
await unwired.refreshOnce();
const unwiredOk = queries2 === 0 && unwired.driftStatus() === null;
if (wiredOk && unwiredOk) {
pass('BW-RT drift runs on refresh when wired; dormant (no db touch) when not');
} else {
fail(
'BW-RT',
`wired(queried=${queries}, status≠null=${wired.driftStatus() !== null}); ` +
`unwired(queried=${queries2}, status=null=${unwired.driftStatus() === null})`
);
}
}
// CW-1 — disagreementMonitor exports the loop machinery + the
// external-source guard constant.
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'indexer', 'price', 'disagreementMonitor.ts'),
'utf-8'
);
if (
src.includes('export function startDisagreementMonitor') &&
src.includes('export async function runDisagreementCheckCycle') &&
src.includes('EXTERNAL_MARKET_SOURCES')
) {
pass('CW-1 disagreementMonitor exports start + runCycle + EXTERNAL_MARKET_SOURCES');
} else {
fail('CW-1', 'C loop machinery missing from disagreementMonitor');
}
}
// CW-2 — factory builds the monitor + shares one native-fetch
// construction with the composite upstream.
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'indexer', 'price', 'factory.ts'),
'utf-8'
);
if (src.includes('export function createDisagreementMonitor') && src.includes('buildMorphitNativeFetch')) {
pass('CW-2 factory builds the disagreement monitor + shares buildMorphitNativeFetch');
} else {
fail('CW-2', 'factory missing createDisagreementMonitor / buildMorphitNativeFetch');
}
}
// CW-3 — main.ts starts C per-asset, hands monitors to /v1/health,
// and stops them on shutdown.
{
const src = readFileSync(resolve(__dirname, '..', 'src', 'main.ts'), 'utf-8');
const hasStart = src.includes('startDisagreementMonitor');
const hasBuild = src.includes('createDisagreementMonitor');
const hasRegistry = src.includes('disagreementMonitors');
const passedToHealth = /healthRoute\([^)]*disagreementMonitors/.test(src);
const stopsOnShutdown = src.includes('stopDisagreementMonitors');
if (hasStart && hasBuild && hasRegistry && passedToHealth && stopsOnShutdown) {
pass('CW-3 main.ts starts C per-asset, passes monitors to /v1/health, stops on shutdown');
} else {
fail(
'CW-3',
`start=${hasStart} build=${hasBuild} registry=${hasRegistry} health=${passedToHealth} stop=${stopsOnShutdown}`
);
}
}
// CW-4 — /v1/health surfaces the disagreement block.
{
const src = readFileSync(resolve(__dirname, '..', 'src', 'api', 'health.ts'), 'utf-8');
if (src.includes('disagreementMonitors') && src.includes('disagreement:') && src.includes('lastCheck()')) {
pass('CW-4 /v1/health surfaces the disagreement block');
} else {
fail('CW-4', 'health.ts no longer surfaces disagreement');
}
}
// Minimal BlurtPriceSource fake for the C cycle runtime tests — only
// currentDetailed() is consulted by runDisagreementCheckCycle.
const fakeSource = (source: string, price: number): BlurtPriceSource => ({
current: () => price,
currentDetailed: () => ({ price, source, updated_at: new Date(), stale: false }),
start: () => {},
stop: () => {}
});
// CW-RT-1 — cycle reads the external (coingecko) published price and
// the freshly-derived native price; divergence → active.
{
const monitor = new DisagreementMonitor('BLURT', 'USD');
const r = await runDisagreementCheckCycle({
monitor,
nativeFetch: async () => 0.01,
priceSource: fakeSource('coingecko', 0.005)
});
if (r.active && r.external_source === 'coingecko' && r.external_price === 0.005 && r.native_price === 0.01) {
pass('CW-RT-1 cycle uses external (coingecko) + native → active on divergence');
} else {
fail('CW-RT-1', `expected active w/ coingecko external, got ${JSON.stringify(r)}`);
}
}
// CW-RT-2 — THE false-alarm guard. When the composite is serving the
// static floor or the native fallback, there is no external market
// reference, so the cycle must stay inactive even though native vs
// floor is a huge gap. This is the property EXTERNAL_MARKET_SOURCES
// exists to protect.
{
const rFloor = await runDisagreementCheckCycle({
monitor: new DisagreementMonitor('BLURT', 'USD'),
nativeFetch: async () => 0.01,
priceSource: fakeSource('static_floor', 0.002)
});
const rNative = await runDisagreementCheckCycle({
monitor: new DisagreementMonitor('BLURT', 'USD'),
nativeFetch: async () => 0.01,
priceSource: fakeSource('morphit_native', 0.01)
});
if (!rFloor.active && rFloor.external_price === null && !rNative.active && rNative.external_price === null) {
pass('CW-RT-2 floor/native published source → no external ref → inactive (false-alarm guard)');
} else {
fail(
'CW-RT-2',
`expected inactive; floor=${JSON.stringify(rFloor)} native=${JSON.stringify(rNative)}`
);
}
}
// CW-RT-3 — startDisagreementMonitor schedules at the given interval
// and the returned stop fn clears that handle.
{
let cleared: unknown = null;
let intervalArg = -1;
const stop = startDisagreementMonitor(
{
monitor: new DisagreementMonitor('BLURT', 'USD'),
nativeFetch: async () => null,
priceSource: fakeSource('coingecko', 0.005),
setInterval: ((_fn: () => void, ms: number) => {
intervalArg = ms;
return 12345 as unknown as ReturnType<typeof setInterval>;
}) as unknown as typeof setInterval,
clearInterval: ((h: unknown) => {
cleared = h;
}) as unknown as typeof clearInterval
},
300_000
);
stop();
if (typeof stop === 'function' && intervalArg === 300_000 && cleared === 12345) {
pass('CW-RT-3 startDisagreementMonitor schedules at interval + stop() clears the handle');
} else {
fail('CW-RT-3', `interval=${intervalArg}, cleared=${String(cleared)}`);
}
}
// FS-1 — peer monitor (defense F) exposes its latest cycle result via
// the startPeerPriceMonitor callback, so /v1/health can read it.
{
const src = readFileSync(
resolve(__dirname, '..', 'src', 'indexer', 'price', 'peerPriceMonitor.ts'),
'utf-8'
);
if (src.includes('onResult') && /startPeerPriceMonitor\([\s\S]*?onResult/.test(src)) {
pass('FS-1 startPeerPriceMonitor exposes its latest cycle via onResult callback');
} else {
fail('FS-1', 'peer monitor no longer exposes its cycle result');
}
}
// FS-2 — main.ts captures peer results + passes them to /v1/health.
{
const src = readFileSync(resolve(__dirname, '..', 'src', 'main.ts'), 'utf-8');
const captures = src.includes('peerMonitorResults') && src.includes('peerMonitorResults.set');
const passedToHealth = /healthRoute\([^)]*peerMonitorResults/.test(src);
if (captures && passedToHealth) {
pass('FS-2 main.ts captures peer results + passes them to /v1/health');
} else {
fail('FS-2', `captures=${captures} health=${passedToHealth}`);
}
}
// FS-3 — /v1/health surfaces the peer block; B + C + F all visible.
{
const src = readFileSync(resolve(__dirname, '..', 'src', 'api', 'health.ts'), 'utf-8');
if (src.includes('peerMonitorResults') && src.includes('peer:') && src.includes('peer_median')) {
pass('FS-3 /v1/health surfaces the peer block (drift + disagreement + peer all visible)');
} else {
fail('FS-3', 'health.ts no longer surfaces the peer block');
}
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\nprice-source-hardening-smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} price-source-hardening scenarios passed`);