morphit/apps/indexer/scripts/peer-price-monitor-smoke.ts

445 lines
14 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Structural smoke for cp129 Defense F — cross-instance peer price
* monitor (peerPriceMonitor.ts).
*
* What this verifies (without spinning up Postgres or real federation
* peers — those are integration concerns):
*
* 1. Module exports the public surface area we expect.
* 2. Sane numeric defaults (threshold, window, sustained, cooldown,
* min observations, retention).
* 3. `median()` pure-function correctness: empty rejected, odd-length
* picks middle, even-length averages two middles, sorted/unsorted
* input produces same result.
* 4. `disagreementExceedsThreshold` correctness: identity (0%), at
* threshold, well above, well below, edge cases (zero peer
* median rejected to avoid div-by-zero).
* 5. `shouldFireAlert` correctness: not-sustained-yet, exactly-at-
* sustained, cooldown-active, cooldown-elapsed, never-fired.
* 6. Doc comment manifest — verify the file's source still mentions
* Defense F + median-not-mean + ≥3 peers + same-denomination
* filter (regression prevention for the design rationale).
*/
import {
PEER_DISAGREEMENT_THRESHOLD,
PEER_DISAGREEMENT_WINDOW_HOURS,
PEER_DISAGREEMENT_SUSTAINED_HOURS,
PEER_ALERT_COOLDOWN_HOURS,
PEER_MIN_OBSERVATIONS,
PEER_SAMPLE_INTERVAL_MINUTES,
PEER_OBSERVATION_RETENTION_DAYS,
PEER_FETCH_TIMEOUT_MS,
median,
disagreementExceedsThreshold,
shouldFireAlert,
runPeerPriceSampleCycle,
startPeerPriceMonitor,
pruneOldObservations,
fetchPeerReceipt,
_resetPeerPriceMonitorState
} from '../src/indexer/price/peerPriceMonitor';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const MODULE_PATH = join(__dirname, '..', 'src', 'indexer', 'price', 'peerPriceMonitor.ts');
let pass = 0;
let fail = 0;
function scenario(name: string, fn: () => void | Promise<void>): void {
try {
const result = fn();
if (result instanceof Promise) {
result
.then(() => {
console.log(`${name}`);
pass++;
})
.catch((err) => {
console.log(`${name}: ${err}`);
fail++;
});
} else {
console.log(`${name}`);
pass++;
}
} catch (err) {
console.log(`${name}: ${err}`);
fail++;
}
}
function assertEq<T>(actual: T, expected: T, label: string): void {
if (actual !== expected) {
throw new Error(`${label}: expected ${String(expected)}, got ${String(actual)}`);
}
}
console.log('\n── peer-price-monitor structural smoke (cp129 Defense F) ──\n');
// ─── PPM-1: public surface area ──
scenario('PPM-1: module exports all expected symbols', () => {
const allFns = [
median,
disagreementExceedsThreshold,
shouldFireAlert,
runPeerPriceSampleCycle,
startPeerPriceMonitor,
pruneOldObservations,
fetchPeerReceipt,
_resetPeerPriceMonitorState
];
for (const fn of allFns) {
if (typeof fn !== 'function') {
throw new Error('expected function export');
}
}
});
// ─── PPM-2: sane numeric defaults ──
scenario('PPM-2: PEER_DISAGREEMENT_THRESHOLD is 0.25 (25%)', () => {
assertEq(PEER_DISAGREEMENT_THRESHOLD, 0.25, 'threshold');
});
scenario('PPM-2: PEER_DISAGREEMENT_WINDOW_HOURS is 4', () => {
assertEq(PEER_DISAGREEMENT_WINDOW_HOURS, 4, 'window hours');
});
scenario('PPM-2: PEER_DISAGREEMENT_SUSTAINED_HOURS is 4', () => {
assertEq(PEER_DISAGREEMENT_SUSTAINED_HOURS, 4, 'sustained hours');
});
scenario('PPM-2: PEER_ALERT_COOLDOWN_HOURS is 24', () => {
assertEq(PEER_ALERT_COOLDOWN_HOURS, 24, 'cooldown hours');
});
scenario('PPM-2: PEER_MIN_OBSERVATIONS is 3 (sybil resistance floor)', () => {
assertEq(PEER_MIN_OBSERVATIONS, 3, 'min observations');
});
scenario('PPM-2: PEER_SAMPLE_INTERVAL_MINUTES is 30', () => {
assertEq(PEER_SAMPLE_INTERVAL_MINUTES, 30, 'sample interval');
});
scenario('PPM-2: PEER_OBSERVATION_RETENTION_DAYS is 7', () => {
assertEq(PEER_OBSERVATION_RETENTION_DAYS, 7, 'retention days');
});
scenario('PPM-2: PEER_FETCH_TIMEOUT_MS is positive and ≤30s', () => {
if (!(PEER_FETCH_TIMEOUT_MS > 0 && PEER_FETCH_TIMEOUT_MS <= 30_000)) {
throw new Error(`bad timeout: ${PEER_FETCH_TIMEOUT_MS}`);
}
});
// ─── PPM-3: median() correctness ──
scenario('PPM-3: median rejects empty array', () => {
try {
median([]);
throw new Error('expected throw on empty');
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('empty')) throw err;
}
});
scenario('PPM-3: median of [1] is 1', () => {
assertEq(median([1]), 1, 'single');
});
scenario('PPM-3: median of [1,2,3] is 2', () => {
assertEq(median([1, 2, 3]), 2, 'odd middle');
});
scenario('PPM-3: median of [1,2,3,4] is 2.5 (even-length average)', () => {
assertEq(median([1, 2, 3, 4]), 2.5, 'even mid avg');
});
scenario('PPM-3: median is sort-invariant', () => {
const a = median([5, 1, 3, 2, 4]);
const b = median([1, 2, 3, 4, 5]);
assertEq(a, b, 'sort invariance');
});
scenario('PPM-3: median resists single outlier (sybil-resistance property)', () => {
// A malicious peer feeding 10x the real price. Median should
// still pick the middle of 5 → 3, not be moved by the outlier.
const result = median([1, 2, 3, 4, 1000]);
assertEq(result, 3, 'outlier-resistant median');
});
// ─── PPM-4: disagreementExceedsThreshold() correctness ──
scenario('PPM-4: identity (price == peer) → false', () => {
assertEq(disagreementExceedsThreshold(0.002, 0.002, 0.25), false, 'identity');
});
scenario('PPM-4: 10% deviation, 25% threshold → false', () => {
// my=0.0022, peer=0.002 → 10% deviation, under 25%
assertEq(disagreementExceedsThreshold(0.0022, 0.002, 0.25), false, '10pct under 25pct');
});
scenario('PPM-4: 30% deviation, 25% threshold → true', () => {
// my=0.0026, peer=0.002 → 30% deviation, over 25%
assertEq(disagreementExceedsThreshold(0.0026, 0.002, 0.25), true, '30pct over 25pct');
});
scenario('PPM-4: detects deviation in BOTH directions (my > peer)', () => {
assertEq(disagreementExceedsThreshold(0.003, 0.002, 0.25), true, 'my-above');
});
scenario('PPM-4: detects deviation in BOTH directions (my < peer)', () => {
assertEq(disagreementExceedsThreshold(0.001, 0.002, 0.25), true, 'my-below');
});
scenario('PPM-4: zero peer median rejected (no div-by-zero, returns false)', () => {
assertEq(disagreementExceedsThreshold(0.005, 0, 0.25), false, 'zero peer');
});
scenario('PPM-4: negative peer median rejected', () => {
assertEq(disagreementExceedsThreshold(0.005, -0.001, 0.25), false, 'negative peer');
});
// ─── PPM-5: shouldFireAlert() correctness ──
scenario('PPM-5: never-disagreed (aboveThresholdSince=null) → false', () => {
const now = new Date('2026-05-23T12:00:00Z');
assertEq(shouldFireAlert(null, now, null, 4, 24), false, 'never disagreed');
});
scenario('PPM-5: just-disagreed-now (not-yet-sustained) → false', () => {
const now = new Date('2026-05-23T12:00:00Z');
const aboveThresholdSince = new Date('2026-05-23T11:55:00Z'); // 5 min ago
assertEq(shouldFireAlert(aboveThresholdSince, now, null, 4, 24), false, 'too fresh');
});
scenario('PPM-5: exactly sustained-hours elapsed → true', () => {
const now = new Date('2026-05-23T12:00:00Z');
const aboveThresholdSince = new Date('2026-05-23T08:00:00Z'); // 4 hours ago
assertEq(
shouldFireAlert(aboveThresholdSince, now, null, 4, 24),
true,
'at exact threshold'
);
});
scenario('PPM-5: sustained but recently alerted (cooldown) → false', () => {
const now = new Date('2026-05-23T12:00:00Z');
const aboveThresholdSince = new Date('2026-05-23T06:00:00Z'); // 6h ago
const lastAlertAt = new Date('2026-05-23T10:00:00Z'); // 2h ago, < 24h cooldown
assertEq(
shouldFireAlert(aboveThresholdSince, now, lastAlertAt, 4, 24),
false,
'cooldown active'
);
});
scenario('PPM-5: sustained + cooldown elapsed → true', () => {
const now = new Date('2026-05-24T12:00:00Z');
const aboveThresholdSince = new Date('2026-05-23T06:00:00Z'); // 30h ago
const lastAlertAt = new Date('2026-05-23T11:00:00Z'); // 25h ago, > 24h cooldown
assertEq(
shouldFireAlert(aboveThresholdSince, now, lastAlertAt, 4, 24),
true,
'cooldown elapsed'
);
});
// ─── PPM-6: doc-comment defense manifest ──
scenario('PPM-6: module source still documents the design pillars', () => {
const src = readFileSync(MODULE_PATH, 'utf-8');
const markers = [
'Defense F',
'median', // median-not-mean reasoning
'≥3 peers', // sybil-resistance floor
'Same-denomination filter', // exclude EUR peer when I'm USD
'self-sovereign' // architectural premise
];
for (const m of markers) {
if (!src.includes(m)) {
throw new Error(`missing marker: ${m}`);
}
}
});
// ─── PPM-7: cp139-F-2 SSRF defense via fetchJson ──
// Pre-cp139-F-2, fetchPeerReceipt called bare fetch() and lacked
// the DNS-rebinding closure / redirect:manual / body-cap defenses
// that federationProbe.fetchJson() has. This batch pins:
// (a) the helper now routes through fetchJson (source sentinel)
// (b) the import is wired
// (c) attacker-controlled peerOrigin pointing at a literal
// private host is rejected by fetchJson's layer-2 denylist
// before any network call fires.
scenario(
'PPM-7-1: fetchPeerReceipt routes through fetchJson (source sentinel)',
() => {
const src = readFileSync(MODULE_PATH, 'utf-8');
if (!src.includes(`from '$indexer/federationProbe'`)) {
throw new Error('expected $indexer/federationProbe import');
}
if (!src.includes('fetchJson<PeerReceiptResponse>(')) {
throw new Error('expected fetchJson<PeerReceiptResponse> call');
}
}
);
scenario('PPM-7-2: fetchPeerReceipt no longer calls bare fetch()', () => {
const src = readFileSync(MODULE_PATH, 'utf-8');
// Look for the old smell. The string `await fetch(` should
// NOT appear in this module — every outbound HTTP call must
// go through fetchJson. This sentinel catches a regression
// that re-introduces a bare fetch() (e.g. for a future second
// endpoint).
if (src.match(/\bawait\s+fetch\s*\(/)) {
throw new Error('regression: bare fetch() in peerPriceMonitor');
}
});
scenario(
'PPM-7-3: cp139-F-2 design-rationale marker present in source',
() => {
const src = readFileSync(MODULE_PATH, 'utf-8');
const markers = [
'cp139-F-2', // explicit finding reference
'DNS-rebinding', // the hardening this closes
'six-layer' // points at federationProbe.fetchJson's defense stack
];
for (const m of markers) {
if (!src.includes(m)) {
throw new Error(`missing marker: ${m}`);
}
}
}
);
scenario(
'PPM-7-4: localhost peerOrigin rejected by fetchJson layer 2',
async () => {
// fetchJson rejects literal-private hostnames before any
// network resolution. We pass localhost as the peer origin
// and expect fetchPeerReceipt to swallow the error and
// return null — caller (sample-cycle loop) interprets null
// as "skip this peer."
const result = await fetchPeerReceipt(
'https://localhost:8443',
'BLURT',
'USD'
);
assertEq(result, null, 'expected null for localhost origin');
}
);
scenario(
'PPM-7-5: 127.0.0.1 peerOrigin rejected by fetchJson layer 2',
async () => {
const result = await fetchPeerReceipt(
'https://127.0.0.1:8443',
'BLURT',
'USD'
);
assertEq(result, null, 'expected null for 127.0.0.1 origin');
}
);
scenario(
'PPM-7-6: 169.254.169.254 (AWS metadata) peerOrigin rejected',
async () => {
const result = await fetchPeerReceipt(
'https://169.254.169.254',
'BLURT',
'USD'
);
assertEq(result, null, 'expected null for AWS metadata origin');
}
);
scenario(
'PPM-7-7: http:// peerOrigin rejected by fetchJson layer 1',
async () => {
// Layer 1: HTTPS protocol enforcement. An attacker who
// chain-registered an http:// origin (rejected at intake,
// but defense-in-depth) reaches the fetch layer and is
// rejected again.
const result = await fetchPeerReceipt(
'http://example.com',
'BLURT',
'USD'
);
assertEq(result, null, 'expected null for http:// origin');
}
);
scenario(
'PPM-7-8: ::1 IPv6 loopback peerOrigin rejected',
async () => {
const result = await fetchPeerReceipt(
'https://[::1]',
'BLURT',
'USD'
);
assertEq(result, null, 'expected null for IPv6 loopback');
}
);
scenario(
'PPM-7-9: .local TLD peerOrigin rejected',
async () => {
const result = await fetchPeerReceipt(
'https://printer.local',
'BLURT',
'USD'
);
assertEq(result, null, 'expected null for .local TLD');
}
);
// ─── PPM-10: fan-out-all architecture guard (cp167 decision lock) ──
//
// peerPriceMonitor MUST query every healthy peer and collect all
// observations (the median + disagreement signal depend on it). It
// must NOT be "migrated" to @morphit/rpc-pool's quorumCall, which
// early-returns on partial consensus and would silently defeat the
// disagreement alert. This sentinel fails if a future refactor
// swaps the fan-out for the pool, forcing a re-read of the in-source
// rationale (and this comment) before regressing the alert.
scenario('PPM-10: fan-out uses Promise.allSettled over peers', () => {
const src = readFileSync(MODULE_PATH, 'utf8');
if (!/Promise\.allSettled\s*\(/.test(src)) {
throw new Error(
'peerPriceMonitor no longer fans out via Promise.allSettled — ' +
'the median/disagreement alert needs EVERY peer observation. ' +
'See the cp167 design-decision comment before changing this.'
);
}
});
scenario('PPM-10: peer fan-out is NOT migrated to rpc-pool quorumCall', () => {
const src = readFileSync(MODULE_PATH, 'utf8');
// quorumCall early-returns on N-agreement among INTERCHANGEABLE
// endpoints; peers are distinct federation members, not
// interchangeable. Match an actual IMPORT of the pool or a real
// quorumCall INVOCATION — not the bare word, which legitimately
// appears in the in-source rationale explaining why it's avoided.
const importsPool = /\bfrom\s+['"]@morphit\/rpc-pool['"]/.test(src);
const callsQuorum = /\bquorumCall\s*[(<]/.test(src);
if (importsPool || callsQuorum) {
throw new Error(
'peerPriceMonitor imports @morphit/rpc-pool or invokes quorumCall — ' +
'this defeats the disagreement alert (early-return on partial ' +
'consensus drops peer observations). cp167 decided against this.'
);
}
});
// Settle async scenarios + report
await new Promise((r) => setTimeout(r, 50));
console.log('\n' + '─'.repeat(56));
if (fail === 0) {
console.log(`✓ all ${pass} scenarios passed`);
process.exit(0);
} else {
console.log(`${fail} of ${pass + fail} scenarios FAILED`);
process.exit(1);
}