morphit/apps/indexer/scripts/btc-quorum-call-integration-smoke.ts

287 lines
8.8 KiB
TypeScript

/**
* Morphit indexer — BTC fee verifier quorum-call integration smoke.
*
* cp166 — proves the actual UX win Ken asked about: when 2 of 4
* configured BTC explorers are down/slow, the verifier returns in
* ~50 ms via the 2 healthy survivors, NOT in 5 s via Promise.allSettled
* waiting on the slow ones' full timeout.
*
* Spins up 4 fake mempool.space-style HTTP servers on ephemeral
* ports, then:
*
* Scenario 1: All 4 healthy + agree → verifier returns fast (sub-100ms)
* Scenario 2: 2 healthy + 2 refusing connection → 2-of-2 quorum
* from survivors, returns fast (sub-200ms)
* Scenario 3: 2 healthy + 2 hanging forever → quorum from healthy
* survivors, returns fast (sub-200ms) — proves we
* don't wait on the slow ones
* Scenario 4: 4 explorers, 3 agree on amount X, 1 dissents on Y →
* majority outvotes the dissenter (minAgree=3),
* verified successfully
*
* Run: tsx scripts/btc-quorum-call-integration-smoke.ts
*/
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { BitcoinExplorerFeeVerifier } from '$indexer/fee/bitcoinExplorerVerifier';
import type { FeeClaim } from '$indexer/fee/verifier';
const ANSI_GREEN = '\x1b[32m';
const ANSI_RED = '\x1b[31m';
const ANSI_RESET = '\x1b[0m';
const FEE_ADDRESS = 'bc1qfeeaddrexample000000000000000000000000';
const VALID_TXID = 'a'.repeat(64);
interface Result {
name: string;
passed: boolean;
detail?: string;
}
const results: Result[] = [];
function pass(name: string): void {
results.push({ name, passed: true });
}
function fail(name: string, detail?: string): void {
results.push({ name, passed: false, detail });
}
/** Spin up a fake explorer that returns the given tx body on /tx/<txid>. */
function startHealthyExplorer(amountSatoshis: number): Promise<{
url: string;
close: () => Promise<void>;
}> {
return new Promise((resolve) => {
const server = createServer((req, res) => {
if (req.url?.includes('/tx/')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
txid: VALID_TXID,
vout: [
{
value: amountSatoshis,
scriptpubkey_address: FEE_ADDRESS
}
],
status: { confirmed: true }
})
);
} else {
res.writeHead(404);
res.end();
}
});
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({
url: `http://127.0.0.1:${port}`,
close: () => new Promise((r) => server.close(() => r()))
});
});
});
}
/** Server that accepts the connection but never responds — simulates
* a hung explorer (no TCP refusal, just silence). */
function startHangingExplorer(): Promise<{
url: string;
close: () => Promise<void>;
}> {
return new Promise((resolve) => {
const server = createServer(() => {
// Never write to res — request hangs.
});
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({
url: `http://127.0.0.1:${port}`,
close: () => new Promise((r) => server.close(() => r()))
});
});
});
}
/** Returns a port that's bound briefly then released — connecting to
* it gives ECONNREFUSED. */
function dummyClosedPortUrl(): Promise<string> {
return new Promise((resolve) => {
const server = createServer();
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
server.close(() => resolve(`http://127.0.0.1:${port}`));
});
});
}
function claim(): FeeClaim {
return {
feeMethod: 'btc',
expectedAmount: 2_500,
externalTxId: VALID_TXID,
permlink: 'x',
signer: 'alice',
txProof: null
};
}
async function main(): Promise<void> {
// ─── Scenario 1: all healthy + agree ───
{
const a = await startHealthyExplorer(2_500);
const b = await startHealthyExplorer(2_500);
const c = await startHealthyExplorer(2_500);
const d = await startHealthyExplorer(2_500);
try {
const v = new BitcoinExplorerFeeVerifier({
feeAddress: FEE_ADDRESS,
explorerUrls: [a.url, b.url, c.url, d.url],
minConfirmations: 1,
requestTimeoutMs: 5_000,
minSuccessfulResponses: 2
});
const t0 = Date.now();
const r = await v.verify(claim());
const elapsed = Date.now() - t0;
if (r.kind === 'verified' && elapsed < 250) {
pass(`scenario 1: 4 healthy explorers → verified in ${elapsed} ms`);
} else {
fail(
'scenario 1: 4 healthy explorers',
`kind=${r.kind} elapsed=${elapsed}`
);
}
} finally {
await Promise.all([a.close(), b.close(), c.close(), d.close()]);
}
}
// ─── Scenario 2: 2 healthy + 2 connection-refused ───
{
const a = await startHealthyExplorer(2_500);
const b = await startHealthyExplorer(2_500);
const dead1 = await dummyClosedPortUrl();
const dead2 = await dummyClosedPortUrl();
try {
const v = new BitcoinExplorerFeeVerifier({
feeAddress: FEE_ADDRESS,
// dead URLs first to force the pool to deal with them
explorerUrls: [dead1, dead2, a.url, b.url],
minConfirmations: 1,
requestTimeoutMs: 5_000,
minSuccessfulResponses: 2
});
const t0 = Date.now();
const r = await v.verify(claim());
const elapsed = Date.now() - t0;
// We expect this to complete fast even with 2 dead URLs.
// ECONNREFUSED fires immediately; the 2 healthy explorers
// respond and form quorum. Should be well under 500 ms.
if (r.kind === 'verified' && elapsed < 500) {
pass(
`scenario 2: 2 healthy + 2 connection-refused → verified in ${elapsed} ms`
);
} else {
fail(
'scenario 2: ECONNREFUSED resilience',
`kind=${r.kind} elapsed=${elapsed}`
);
}
} finally {
await Promise.all([a.close(), b.close()]);
}
}
// ─── Scenario 3: 2 healthy + 2 hanging (THE actual choke-point fix) ───
{
const a = await startHealthyExplorer(2_500);
const b = await startHealthyExplorer(2_500);
const hung1 = await startHangingExplorer();
const hung2 = await startHangingExplorer();
try {
// The healthy explorers respond in <50 ms; the hanging
// ones would hold the request open for the full 5_000 ms
// timeout under the old Promise.allSettled pattern.
// Under cp166 quorumCall, the call returns as soon as
// the 2 healthy form quorum, so we should be sub-500 ms.
const v = new BitcoinExplorerFeeVerifier({
feeAddress: FEE_ADDRESS,
explorerUrls: [hung1.url, hung2.url, a.url, b.url],
minConfirmations: 1,
requestTimeoutMs: 5_000,
minSuccessfulResponses: 2
});
const t0 = Date.now();
const r = await v.verify(claim());
const elapsed = Date.now() - t0;
if (r.kind === 'verified' && elapsed < 500) {
pass(
`scenario 3: 2 healthy + 2 hanging → verified in ${elapsed} ms (no hang on the slow ones)`
);
} else {
fail(
'scenario 3: hung-explorer choke-point fix',
`kind=${r.kind} elapsed=${elapsed} (expected verified in <500 ms; this is the actual UX win)`
);
}
} finally {
await Promise.all([a.close(), b.close(), hung1.close(), hung2.close()]);
}
}
// ─── Scenario 4: dissenter is outvoted (3 agree, 1 disagrees) ───
{
const a = await startHealthyExplorer(2_500);
const b = await startHealthyExplorer(2_500);
const c = await startHealthyExplorer(2_500);
const dissenter = await startHealthyExplorer(1_000);
try {
const v = new BitcoinExplorerFeeVerifier({
feeAddress: FEE_ADDRESS,
explorerUrls: [a.url, b.url, c.url, dissenter.url],
minConfirmations: 1,
requestTimeoutMs: 5_000,
minSuccessfulResponses: 3
});
const r = await v.verify(claim());
if (r.kind === 'verified' && r.observedAmount === 2_500) {
pass(
'scenario 4: 3 agree + 1 dissents → majority outvotes dissenter (verified at agreed amount)'
);
} else {
fail(
'scenario 4: dissenter outvoted',
`kind=${r.kind} observed=${(r as { observedAmount?: number }).observedAmount}`
);
}
} finally {
await Promise.all([a.close(), b.close(), c.close(), dissenter.close()]);
}
}
// Report
let failed = 0;
for (const r of results) {
if (r.passed) {
console.log(' ' + ANSI_GREEN + '✓' + ANSI_RESET + ' ' + r.name);
} else {
console.log(' ' + ANSI_RED + '✗' + ANSI_RESET + ' ' + r.name);
if (r.detail) console.log(' ' + r.detail);
failed++;
}
}
console.log();
console.log('──────────────────────────────────────────────────────');
if (failed > 0) {
console.log('✗ ' + failed + ' of ' + results.length + ' scenarios failed');
process.exit(1);
} else {
console.log('✓ all ' + results.length + ' scenarios passed');
}
}
main().catch((err) => {
console.error('smoke crashed:', err);
process.exit(1);
});