morphit/apps/indexer/scripts/explorer-txid-echo-smoke.ts

310 lines
9.9 KiB
TypeScript

/**
* Morphit indexer — explorer txid-echo verification smoke
* (Item 4, Audit Part 26).
*
* Verifies that BOTH the BTC and XMR explorer verifiers reject
* responses where the explorer's echoed transaction id doesn't
* match what we asked for.
*
* Threat model (honest framing): this defense is narrower than
* the original REVISIT-LIST §F.6 spec ("secp256k1 verify of
* off-chain fee txns"). Verifying signatures on an
* explorer-returned tx doesn't actually defend against a lying
* explorer — anyone can produce a perfectly-valid signed BTC
* or XMR transaction; the question is whether it's in the
* blockchain. Verifying inclusion would require SPV merkle
* proofs against a header chain (BTC) or out-of-band view-key
* decryption (XMR), both of which are substantially bigger than
* "~150 lines per chain." See AUDIT-2026-05.md Part 26 for
* the full pushback narrative.
*
* What this smoke does test is real and useful: an explorer
* returning the right SHAPE but a wrong TXID is a clear bug
* (or trivial-attack signal) that the existing two-explorer
* cross-check could miss if both explorers happened to
* misroute coherently. Adding a per-response txid-echo guard
* before the cross-check is cheap (one comparison per response)
* and catches a real class of failures.
*
* Scenarios:
*
* 1. BTC: explorer echoes the requested txid → accepted (parsed)
* 2. BTC: explorer echoes a DIFFERENT txid → rejected as bad shape
* 3. BTC: explorer echoes upper-case hex of the same txid → accepted
* 4. XMR: explorer echoes the requested tx_hash → accepted
* 5. XMR: explorer echoes a DIFFERENT tx_hash → rejected
* 6. XMR: explorer omits tx_hash entirely → accepted (not a regression)
*
* Usage:
* tsx apps/indexer/scripts/explorer-txid-echo-smoke.ts
*/
import { BitcoinExplorerFeeVerifier } from '../src/indexer/fee/bitcoinExplorerVerifier.ts';
import { MoneroProofFeeVerifier } from '../src/indexer/fee/moneroProofVerifier.ts';
import type { FeeClaim } from '../src/indexer/fee/verifier.ts';
const BTC_FEE_ADDRESS = 'bc1qfeeaddrexample000000000000000000000000';
const XMR_FEE_ADDRESS =
'4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2bYXZKK';
// A Part 108++ tx_proof string. In production this is generated by
// the user's own Monero wallet via `get_tx_proof` (CLI), the GUI's
// "Prove transaction" dialog, or the equivalent in Cake / Feather.
// The prefix `OutProofV2` and base58-ish charset are validated by
// both the order-handler structural validator AND the verifier.
// This is a synthetic string for testing — not a real proof.
const VALID_TX_PROOF =
'OutProofV2' +
'aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789' +
'aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789' +
'aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
const VALID_TXID = 'a'.repeat(64);
const WRONG_TXID = 'b'.repeat(64);
let passed = 0;
const failures: string[] = [];
function ok(name: string, cond: boolean, why = ''): void {
if (cond) {
passed++;
return;
}
failures.push(`${name}${why ? ': ' + why : ''}`);
}
// ─── BTC mocks ────────────────────────────────────────────────
function btcFetch(returnedTxid: string): typeof fetch {
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 () => '900000'
} as unknown as Response;
}
if (url.includes('/tx/')) {
return {
ok: true,
status: 200,
json: async () => ({
txid: returnedTxid,
vout: [
{
scriptpubkey_address: BTC_FEE_ADDRESS,
value: 5000
}
],
status: {
confirmed: true,
block_height: 899999
}
})
} as unknown as Response;
}
throw new Error(`smoke: unmocked URL ${url}`);
}) as unknown as typeof fetch;
}
function btcClaim(): FeeClaim {
return {
feeMethod: 'btc',
expectedAmount: 2_500,
externalTxId: VALID_TXID,
// cp474 — REQUIRED by FeeClaim; `undefined` is not `null`, and the
// Monero verifier discriminates on `txProof === null`.
txProof: null,
permlink: 'my-order-01',
signer: 'alice'
};
}
// ─── XMR mocks ────────────────────────────────────────────────
function xmrFetch(returnedTxid: string | undefined): typeof fetch {
return (async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/outputs')) {
return {
ok: true,
status: 200,
json: async () => ({
status: 'success',
data: {
address: XMR_FEE_ADDRESS,
...(returnedTxid !== undefined ? { tx_hash: returnedTxid } : {}),
outputs: [{ amount: 1_000_000_000_000, match: true }],
tx_confirmations: 5
}
})
} as unknown as Response;
}
throw new Error(`smoke: unmocked URL ${url}`);
}) as unknown as typeof fetch;
}
function xmrClaim(): FeeClaim {
return {
feeMethod: 'xmr',
// XMR amounts are in piconero (1 XMR = 1e12 piconero) so
// they must be bigint to avoid Number precision loss; the
// verifier rejects non-bigint as a defensive guard.
expectedAmount: 1_000_000_000_000n,
externalTxId: VALID_TXID,
txProof: VALID_TX_PROOF,
permlink: 'my-order-02',
signer: 'alice'
};
}
// ─── tests ────────────────────────────────────────────────────
async function run(): Promise<void> {
console.log('explorer txid-echo smoke');
// ─── BTC scenario 1: matching txid → accepted ────────────
{
const v = new BitcoinExplorerFeeVerifier(
{
feeAddress: BTC_FEE_ADDRESS,
explorerUrls: ['https://example-explorer.test/api'],
minConfirmations: 1,
requestTimeoutMs: 1000,
// cp474 — Part 109 quorum gate; required by the config type.
minSuccessfulResponses: 1
},
btcFetch(VALID_TXID)
);
const r = await v.verify(btcClaim());
ok(
'BTC: matching txid → verified',
r.kind === 'verified',
`got kind=${r.kind}${r.kind === 'rejected' ? ` reason=${r.reason}` : ''}`
);
}
// ─── BTC scenario 2: wrong txid → rejected as bad shape ──
{
const v = new BitcoinExplorerFeeVerifier(
{
feeAddress: BTC_FEE_ADDRESS,
explorerUrls: ['https://example-explorer.test/api'],
minConfirmations: 1,
requestTimeoutMs: 1000,
// cp474 — Part 109 quorum gate; required by the config type.
minSuccessfulResponses: 1
},
btcFetch(WRONG_TXID)
);
const r = await v.verify(btcClaim());
// With only one (wrong-shape-rejected) explorer, the
// verifier returns pending_external — no successful
// responses to act on.
ok(
'BTC: wrong txid → not verified (pending or rejected)',
r.kind !== 'verified',
`got kind=${r.kind} (should not be verified)`
);
}
// ─── BTC scenario 3: upper-case hex echo → accepted ─────
{
const v = new BitcoinExplorerFeeVerifier(
{
feeAddress: BTC_FEE_ADDRESS,
explorerUrls: ['https://example-explorer.test/api'],
minConfirmations: 1,
requestTimeoutMs: 1000,
// cp474 — Part 109 quorum gate; required by the config type.
minSuccessfulResponses: 1
},
btcFetch(VALID_TXID.toUpperCase())
);
const r = await v.verify(btcClaim());
ok(
'BTC: upper-case hex echo → verified (case-insensitive match)',
r.kind === 'verified',
`got kind=${r.kind}${r.kind === 'rejected' ? ` reason=${r.reason}` : ''}`
);
}
// ─── XMR scenario 4: matching tx_hash → accepted ─────────
{
const v = new MoneroProofFeeVerifier(
{
feeAddress: XMR_FEE_ADDRESS,
explorerUrls: ['https://example-explorer.test'],
minConfirmations: 1,
requestTimeoutMs: 1000,
// cp474 — Part 109 quorum gate; required by the config type.
minSuccessfulResponses: 1
},
xmrFetch(VALID_TXID)
);
const r = await v.verify(xmrClaim());
ok(
'XMR: matching tx_hash → verified',
r.kind === 'verified',
`got kind=${r.kind}${r.kind === 'rejected' ? ` reason=${r.reason}` : ''}`
);
}
// ─── XMR scenario 5: wrong tx_hash → not verified ────────
{
const v = new MoneroProofFeeVerifier(
{
feeAddress: XMR_FEE_ADDRESS,
explorerUrls: ['https://example-explorer.test'],
minConfirmations: 1,
requestTimeoutMs: 1000,
// cp474 — Part 109 quorum gate; required by the config type.
minSuccessfulResponses: 1
},
xmrFetch(WRONG_TXID)
);
const r = await v.verify(xmrClaim());
ok(
'XMR: wrong tx_hash → not verified',
r.kind !== 'verified',
`got kind=${r.kind} (should not be verified)`
);
}
// ─── XMR scenario 6: missing tx_hash → still accepted ────
// We don't reject on absence (some explorer implementations
// omit it under various conditions) — only on mismatch. This
// is by design: stricter explorers do echo, but we don't want
// to break compatibility with older or minimal implementations
// that don't.
{
const v = new MoneroProofFeeVerifier(
{
feeAddress: XMR_FEE_ADDRESS,
explorerUrls: ['https://example-explorer.test'],
minConfirmations: 1,
requestTimeoutMs: 1000,
// cp474 — Part 109 quorum gate; required by the config type.
minSuccessfulResponses: 1
},
xmrFetch(undefined)
);
const r = await v.verify(xmrClaim());
ok(
'XMR: missing tx_hash → still verified (compat-permissive)',
r.kind === 'verified',
`got kind=${r.kind}${r.kind === 'rejected' ? ` reason=${r.reason}` : ''}`
);
}
console.log(`\n${'─'.repeat(60)}`);
if (failures.length === 0) {
console.log(`✓ all ${passed} scenarios passed`);
process.exit(0);
} else {
for (const f of failures) console.log(f);
console.log(`${failures.length}/${passed + failures.length} scenarios failed`);
process.exit(1);
}
}
await run();