morphit/apps/indexer/scripts/monero-jitter-smoke.ts

171 lines
4.8 KiB
TypeScript

/**
* Smoke: jitterMoneroAmount helper (Q5 unlinkability).
*
* Verifies:
* - Output is always >= input (no underpayment).
* - Output is at most input + 1e-6 XMR (jitter ≤ 999_999 piconero).
* - Output is 12-decimal-precise.
* - Same input across many calls produces different outputs
* (RNG actually varies; ≥99% unique across 100 calls).
* - Malformed inputs throw.
* - Edge cases: integer inputs, already-fractional inputs,
* trailing-zero inputs.
*
* Runs in Node with crypto.webcrypto; no DOM dependency.
*/
import { jitterMoneroAmount } from '../../web/src/lib/chat/payload.ts';
let scenarios = 0;
let failures = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}: ${err instanceof Error ? err.message : String(err)}`);
}
}
console.log('\n── monero-jitter smoke ───────────────────────────────────\n');
function parsePico(s: string): bigint {
const [w, f = ''] = s.split('.');
const padded = (f + '000000000000').slice(0, 12);
return BigInt(w) * 1_000_000_000_000n + BigInt(padded);
}
scenario('output >= input always (sample 200)', () => {
const base = '0.5';
const basePico = parsePico(base);
for (let i = 0; i < 200; i++) {
const out = jitterMoneroAmount(base);
const outPico = parsePico(out);
if (outPico < basePico) {
throw new Error(`output ${out} < input ${base} on iteration ${i}`);
}
}
});
scenario('output <= input + 1e-6 XMR always', () => {
const base = '0.5';
const basePico = parsePico(base);
const ceilingPico = basePico + 999_999n;
for (let i = 0; i < 200; i++) {
const out = jitterMoneroAmount(base);
const outPico = parsePico(out);
if (outPico > ceilingPico) {
throw new Error(`output ${out} exceeds input + 999999 piconero`);
}
}
});
scenario('output is 12-decimal-precise', () => {
const out = jitterMoneroAmount('1');
if (!/^\d+\.\d{12}$/.test(out)) {
throw new Error(`bad shape: ${out}`);
}
});
scenario('different calls produce different outputs', () => {
const seen = new Set<string>();
for (let i = 0; i < 100; i++) {
seen.add(jitterMoneroAmount('0.5'));
}
// Allowing up to 1 collision since RNG can technically repeat,
// but at 1 in a million we expect 99 unique out of 100.
if (seen.size < 95) {
throw new Error(`only ${seen.size} unique out of 100 — RNG broken?`);
}
});
scenario('integer input works', () => {
const out = jitterMoneroAmount('5');
const outPico = parsePico(out);
if (outPico < 5_000_000_000_000n || outPico > 5_000_000_999_999n) {
throw new Error(`out-of-range: ${out}`);
}
});
scenario('high-precision input works', () => {
const out = jitterMoneroAmount('0.123456789012');
const outPico = parsePico(out);
const inPico = parsePico('0.123456789012');
if (outPico < inPico || outPico > inPico + 999_999n) {
throw new Error(`out-of-range: ${out}`);
}
});
scenario('zero input works', () => {
// Edge case: a 0-amount transfer with jitter pays the seller a
// trivial dust. Not a sensible trade, but the helper should
// not crash.
const out = jitterMoneroAmount('0');
const outPico = parsePico(out);
if (outPico > 999_999n) throw new Error(`unexpected: ${out}`);
});
scenario('rejects malformed input: empty', () => {
try {
jitterMoneroAmount('');
throw new Error('did not throw');
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('invalid')) {
throw err;
}
}
});
scenario('rejects malformed input: non-numeric', () => {
try {
jitterMoneroAmount('abc');
throw new Error('did not throw');
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('invalid')) {
throw err;
}
}
});
scenario('rejects malformed input: 13+ decimals', () => {
try {
jitterMoneroAmount('0.1234567890123');
throw new Error('did not throw');
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('invalid')) {
throw err;
}
}
});
scenario('rejects malformed input: negative', () => {
try {
jitterMoneroAmount('-1');
throw new Error('did not throw');
} catch (err) {
if (!(err instanceof Error) || !err.message.includes('invalid')) {
throw err;
}
}
});
scenario('AMOUNT_RE accepts the jittered output', () => {
// The wire-format AMOUNT_RE allows 1..12 digits whole + 1..12
// fractional. The jitter helper produces 12 fractional digits
// — the tightest the regex allows.
const out = jitterMoneroAmount('0.5');
if (!/^\d{1,12}(?:\.\d{1,12})?$/.test(out)) {
throw new Error(`AMOUNT_RE mismatch: ${out}`);
}
});
console.log(`\n${'─'.repeat(54)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}