310 lines
9.9 KiB
TypeScript
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();
|