morphit/scripts/canary-btc-failover-smoke.ts

212 lines
8.3 KiB
TypeScript

#!/usr/bin/env tsx
/**
* scripts/canary-btc-failover-smoke.ts
*
* cp613 — the canary hit blockstream.info alone for its Bitcoin freshness
* proof, no fallback + fatal abort; a single timeout killed the whole refresh.
* cp614 — widened to FIVE independent providers (Blockstream, mempool.space,
* Blockchain.com, Blockchair, BlockCypher), each with its own HTTP shape, plus
* six independent news feeds. This smoke locks that in:
*
* A. LOGIC — the pure core (btcHeadFailover.ts): the failover walk hops/stops
* with an injected fetchOne, and `parseBtcSourceBody` extracts a tip from
* EACH provider shape (and rejects junk) — all with no network.
* B. WIRING — the source list is the canonical one from operator-config (≥5
* providers across heterogeneous shapes), the CLI dispatches through the
* pure parser + failover walk, generate.sh calls the CLI and degrades
* instead of aborting, and the news line has ≥5 independent feeds.
*/
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEFAULT_CANARY_BTC_SOURCES, type CanaryBtcSource } from '@morphit/operator-config';
import {
type BtcHead,
fetchBtcHeadWithFailover,
parseBtcSourceBody,
parseBtcTip,
resolveCanaryBtcSources
} from './canary/btcHeadFailover.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..');
console.log('\n── canary BTC failover smoke ──────────────────────────\n');
let pass = 0;
const fails: string[] = [];
function check(desc: string, ok: boolean): void {
if (ok) {
pass++;
console.log(`${desc}`);
} else {
fails.push(desc);
console.log(`${desc}`);
}
}
// A stand-in tip hash: 64 lowercase hex chars (leading zeros like a real tip).
const H = '0'.repeat(19) + 'a'.repeat(45); // 19 + 45 = 64
const GOOD: BtcHead = { height: 840_123, hash: H };
const esplora = (url: string): CanaryBtcSource => ({ kind: 'esplora', url, label: url });
async function run(): Promise<void> {
// ─── A. LOGIC — failover walk (no network) ───────────────────────
{
const tried: string[] = [];
const fetchOne = async (s: CanaryBtcSource): Promise<BtcHead | null> => {
tried.push(s.url);
return s.url === 'c' ? GOOD : null;
};
const got = await fetchBtcHeadWithFailover(
[esplora('a'), esplora('b'), esplora('c'), esplora('d')],
fetchOne
);
check(
'failover hops past dead providers to the first that answers',
got !== null && got.source.url === 'c' && got.head.height === 840_123
);
check('failover STOPS at the first success (no d)', tried.join(',') === 'a,b,c');
}
{
const got = await fetchBtcHeadWithFailover([esplora('a'), esplora('b')], async () => null);
check('failover returns null when every provider fails', got === null);
}
{
let calls = 0;
const got = await fetchBtcHeadWithFailover([esplora('a'), esplora('b')], async () => {
calls++;
return GOOD;
});
check('a healthy first provider short-circuits the rest (1 call)', got !== null && calls === 1);
}
// ─── resolveCanaryBtcSources semantics ───────────────────────────
const canonical: CanaryBtcSource[] = [
{ kind: 'esplora', url: 'https://one.example/api', label: 'one.example' },
{ kind: 'blockchair', url: 'https://two.example', label: 'two.example' }
];
check(
'no override → the FULL canonical list',
resolveCanaryBtcSources(undefined, canonical).length === 2 &&
resolveCanaryBtcSources(' ', canonical).length === 2
);
{
const pinned = resolveCanaryBtcSources('https://pinned.example/api', canonical);
check(
'an explicit override pins exactly one Esplora source',
pinned.length === 1 && pinned[0].kind === 'esplora' && pinned[0].url === 'https://pinned.example/api'
);
}
// ─── parseBtcTip shape guard ─────────────────────────────────────
check('parseBtcTip accepts a well-formed height + hash', parseBtcTip('840123', H) !== null);
check(
'parseBtcTip lowercases an uppercase hash and still accepts it',
parseBtcTip('840123', H.toUpperCase())?.hash === H
);
check(
'parseBtcTip rejects junk (empty/non-numeric/zero height, short/non-hex hash)',
parseBtcTip('', H) === null &&
parseBtcTip('x', H) === null &&
parseBtcTip('0', H) === null &&
parseBtcTip('840123', 'short') === null &&
parseBtcTip('840123', 'g'.repeat(64)) === null
);
// ─── parseBtcSourceBody: EVERY provider shape (the cp614 core) ────
check('parseBtcSourceBody(esplora) parses two-body height+hash', parseBtcSourceBody('esplora', '840123', H) !== null);
check(
'parseBtcSourceBody(blockchain_info) parses { height, hash }',
parseBtcSourceBody('blockchain_info', JSON.stringify({ height: 840123, hash: H }))?.height === 840123
);
check(
'parseBtcSourceBody(blockchair) parses { data: { best_block_height, best_block_hash } }',
parseBtcSourceBody(
'blockchair',
JSON.stringify({ data: { best_block_height: 840123, best_block_hash: H } })
)?.hash === H
);
check(
'parseBtcSourceBody(blockcypher) parses { height, hash }',
parseBtcSourceBody('blockcypher', JSON.stringify({ height: 840123, hash: H }))?.height === 840123
);
check(
'parseBtcSourceBody rejects malformed bodies for every shape',
parseBtcSourceBody('blockchain_info', 'not json') === null &&
parseBtcSourceBody('blockchain_info', JSON.stringify({ height: 840123 })) === null &&
parseBtcSourceBody('blockchair', JSON.stringify({ data: {} })) === null &&
parseBtcSourceBody('blockcypher', JSON.stringify({})) === null &&
parseBtcSourceBody('esplora', 'nope', H) === null
);
// ─── B. WIRING — canonical list + CLI + generate.sh ──────────────
check(
'canonical BTC source list spans ≥5 independent providers',
DEFAULT_CANARY_BTC_SOURCES.length >= 5
);
{
const kinds = new Set(DEFAULT_CANARY_BTC_SOURCES.map((s) => s.kind));
check(
'BTC sources cover heterogeneous shapes (esplora + blockchain_info + blockchair + blockcypher)',
kinds.has('esplora') &&
kinds.has('blockchain_info') &&
kinds.has('blockchair') &&
kinds.has('blockcypher')
);
}
const cli = readFileSync(join(REPO, 'scripts/canary/fetch-btc-head.ts'), 'utf8');
const gen = readFileSync(join(REPO, 'scripts/canary/generate.sh'), 'utf8');
check(
'CLI imports DEFAULT_CANARY_BTC_SOURCES from @morphit/operator-config (one source of truth)',
/import\s*\{[^}]*\bDEFAULT_CANARY_BTC_SOURCES\b[^}]*\}\s*from\s*'@morphit\/operator-config'/.test(cli)
);
check(
'CLI hand-copies NO provider URLs of its own (all come from the imported list)',
!/https?:\/\/[a-z0-9.-]*(blockstream|mempool|blockchair|blockchain\.info|blockcypher)/i.test(cli)
);
check(
'CLI dispatches through the pure parser + failover walk',
cli.includes('parseBtcSourceBody(') && cli.includes('fetchBtcHeadWithFailover(')
);
check(
'generate.sh invokes the BTC failover helper via tsx (real call, not a comment)',
/"\$RUN_TSX"\s+"\$REPO_ROOT\/scripts\/canary\/fetch-btc-head\.ts"/.test(gen)
);
check(
'generate.sh no longer curls a single pinned blockstream.info endpoint',
!/curl[^\n]*blockstream\.info/.test(gen)
);
check(
'generate.sh treats total BTC-source failure as NON-fatal (degrades, no exit)',
/fetch-btc-head\.ts"\s*\|\|\s*true/.test(gen) && /unavailable at signing time/.test(gen)
);
{
const newsFeeds = [
'feeds.bbci.co.uk',
'theguardian.com',
'feeds.npr.org',
'aljazeera.com',
'rss.nytimes.com'
];
check(
'news line has ≥5 independent fallback feeds (beyond the operator feed)',
newsFeeds.every((f) => gen.includes(f))
);
}
// ─── verdict ─────────────────────────────────────────────────────
const total = pass + fails.length;
console.log('\n──────────────────────────────────────────────────────');
if (fails.length > 0) {
console.log(`${fails.length} of ${total} canary-btc-failover checks FAILED`);
process.exit(1);
}
console.log(`✓ all ${total} canary-btc-failover scenarios passed`);
}
void run();