morphit/apps/indexer/scripts/fee-tolerance-smoke.ts

152 lines
6.5 KiB
TypeScript

/**
* fee-tolerance-smoke (cp372) — Model-A verification tolerance.
*
* The chain-pinned fee amount stays the enforced target (anti-fork),
* but the verifier accepts a payment within FEE_PRICE_TOLERANCE
* *below* it so a user paying the live-displayed amount isn't
* rejected when crypto appreciated since the operator last re-pinned.
* Overpayment is always fine (floor); only the lower bound relaxes.
*
* Money guard — three layers:
* 1. helper math (minAcceptableSatoshis / minAcceptablePiconero)
* 2. BTC behavioral: a real BitcoinExplorerFeeVerifier against a
* mocked explorer accepts ≥ min, rejects < min
* 3. structural tamper: both verifiers must route through the
* tolerance helpers, never the raw `< claim.expectedAmount`
*
* Run: npx tsx --tsconfig ../../tsconfig.smoke.json scripts/fee-tolerance-smoke.ts
*/
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
minAcceptableSatoshis,
minAcceptablePiconero,
FEE_PRICE_TOLERANCE,
FEE_FALLBACK
} from '@morphit/asset-registry';
import { BitcoinExplorerFeeVerifier } from '../src/indexer/fee/bitcoinExplorerVerifier.ts';
import type { FeeClaim } from '../src/indexer/fee/verifier.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FEE_ADDRESS = 'bc1qfeeaddrexample000000000000000000000000';
const VALID_TXID = 'a'.repeat(64);
let passed = 0;
let failed = 0;
function check(name: string, cond: boolean): void {
if (cond) {
passed++;
console.log(` \u2713 ${name}`);
} else {
failed++;
console.log(` \u2717 ${name}`);
}
}
function btcFetch(observedSats: number): typeof fetch {
const txBody = {
txid: VALID_TXID,
vout: [{ value: observedSats, scriptpubkey_address: FEE_ADDRESS }],
status: { confirmed: true, block_height: 800_000 }
};
return (async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/blocks/tip/height')) {
return { ok: true, status: 200, text: async () => '800000' } as unknown as Response;
}
if (url.includes('/tx/')) {
return { ok: true, status: 200, json: async () => txBody } as unknown as Response;
}
throw new Error(`unmocked URL ${url}`);
}) as unknown as typeof fetch;
}
function btcVerifier(observedSats: number): BitcoinExplorerFeeVerifier {
return new BitcoinExplorerFeeVerifier(
{
feeAddress: FEE_ADDRESS,
explorerUrls: ['https://blockstream.info/api'],
minConfirmations: 1,
requestTimeoutMs: 5_000,
// cp474 — Part 109 quorum gate; required by the config type.
minSuccessfulResponses: 1
},
btcFetch(observedSats)
);
}
const claim = (expectedAmount: number): FeeClaim => ({
feeMethod: 'btc',
expectedAmount,
externalTxId: VALID_TXID,
// cp474 — REQUIRED by FeeClaim; `undefined` is not `null`, and the Monero
// verifier discriminates on `txProof === null`.
txProof: null,
permlink: 'order-01',
signer: 'alice'
});
async function main(): Promise<void> {
console.log('\n\u2500\u2500\u2500 fee-tolerance smoke (cp372, Model A) \u2500\u2500\u2500');
// ── 1. helper math ──────────────────────────────────────────
check('minAcceptableSatoshis(2500) === 2125 (floor 85%)', minAcceptableSatoshis(2500) === 2125);
check('minAcceptableSatoshis(417 fallback) === 354', minAcceptableSatoshis(FEE_FALLBACK.satoshis) === 354);
check('minAcceptableSatoshis(1) === 0 (floors below 1)', minAcceptableSatoshis(1) === 0);
check('minAcceptableSatoshis(0) === 0 (guard)', minAcceptableSatoshis(0) === 0);
check('minAcceptableSatoshis(-5) === 0 (guard)', minAcceptableSatoshis(-5) === 0);
check('minAcceptableSatoshis(NaN) === 0 (guard)', minAcceptableSatoshis(NaN) === 0);
check('minAcceptablePiconero(781250000n) === 664062500n', minAcceptablePiconero(781_250_000n) === 664_062_500n);
check('minAcceptablePiconero(FEE_FALLBACK.piconero) === 664062500n', minAcceptablePiconero(FEE_FALLBACK.piconero) === 664_062_500n);
check('minAcceptablePiconero(0n) === 0n (guard)', minAcceptablePiconero(0n) === 0n);
check('minAcceptablePiconero(-5n) === 0n (guard)', minAcceptablePiconero(-5n) === 0n);
// Tolerance band identity: min == expected*(1-tol) at the reference.
check('satoshi band == round-trip of FEE_PRICE_TOLERANCE', minAcceptableSatoshis(2000) === Math.floor(2000 * (1 - FEE_PRICE_TOLERANCE)));
// ── 2. BTC behavioral (real verifier + mocked explorer) ─────
// expected 2500 → min 2125.
{
const r = await btcVerifier(2500).verify(claim(2500));
check('btc: exact pinned amount (2500) → verified', r.kind === 'verified');
}
{
const r = await btcVerifier(2125).verify(claim(2500));
check('btc: exactly at min (2125) → verified', r.kind === 'verified');
}
{
const r = await btcVerifier(2200).verify(claim(2500));
check('btc: within tolerance (2200, ~12% under) → verified', r.kind === 'verified');
}
{
const r = await btcVerifier(2124).verify(claim(2500));
check('btc: one sat below min (2124) → rejected (underpaid)', r.kind === 'rejected');
}
{
const r = await btcVerifier(2000).verify(claim(2500));
check('btc: well below min (2000, 20% under) → rejected', r.kind === 'rejected');
}
{
const r = await btcVerifier(3000).verify(claim(2500));
check('btc: overpayment (3000) → verified (floor, no upper bound)', r.kind === 'verified');
}
// ── 3. structural tamper guards ─────────────────────────────
const btcSrc = readFileSync(resolve(__dirname, '..', 'src', 'indexer', 'fee', 'bitcoinExplorerVerifier.ts'), 'utf-8');
const xmrSrc = readFileSync(resolve(__dirname, '..', 'src', 'indexer', 'fee', 'moneroProofVerifier.ts'), 'utf-8');
check('tamper: btc verifier routes through minAcceptableSatoshis', btcSrc.includes('minAcceptableSatoshis('));
check('tamper: btc verifier no longer compares raw `observedSats < claim.expectedAmount`', !btcSrc.includes('observedSats < claim.expectedAmount'));
check('tamper: xmr verifier routes through minAcceptablePiconero', xmrSrc.includes('minAcceptablePiconero('));
check('tamper: xmr verifier no longer compares raw `observed < claim.expectedAmount`', !xmrSrc.includes('observed < claim.expectedAmount'));
console.log('\u2500'.repeat(56));
if (failed === 0) {
console.log(`\u2713 all ${passed} fee-tolerance scenarios passed`);
} else {
console.log(`${passed} passed, ${failed} failed (${passed + failed} total)`);
console.log('fee-tolerance-smoke FAILED');
process.exit(1);
}
}
void main();