morphit/apps/indexer/scripts/pnl-smoke.ts

435 lines
13 KiB
TypeScript

/**
* Morphit smoke — P&L categorizer + CSV builder.
*
* Pure logic; verifies categorization rules, CSV quoting,
* CSV-injection mitigation, date filtering, and timestamp
* normalization.
*/
import {
categorizeOp,
filterByDateRange,
type HistoryOp,
type CategorizerPredicates,
type PnlRow
} from '../../web/src/lib/pnl/categorize';
import { buildPnlCsv, type CsvHeaders, type CategoryLabels } from '../../web/src/lib/pnl/exportCsv';
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── pnl smoke ────────────────────────────────────────────\n');
const PREDS: CategorizerPredicates = {
isFeesAccount: (n) => n === 'morphit-fees',
isOperatorAccount: (n) => n === 'morphit',
isFeaturedBidMemo: (m) => /^featured-bid:/.test(m)
};
function transferOp(args: {
from: string;
to: string;
amount: string;
memo?: string;
timestamp?: string;
}): HistoryOp {
return {
block: 100_000,
trx_id: 'abc123',
timestamp: args.timestamp ?? '2024-08-15T14:32:18',
op: ['transfer', { from: args.from, to: args.to, amount: args.amount, memo: args.memo ?? '' }]
};
}
// ─── categorizeOp ───────────────────────────────────────────────────
scenario('categorizeOp: incoming BLURT from random user → blurt_received', () => {
const r = categorizeOp(
transferOp({ from: 'alice', to: 'me', amount: '10.000 BLURT' }),
'me',
PREDS
);
if (!r) throw new Error('expected row');
if (r.category !== 'blurt_received') throw new Error(`got ${r.category}`);
if (r.blurtSigned !== 10) throw new Error(`amt ${r.blurtSigned}`);
if (r.counterparty !== 'alice') throw new Error('counterparty');
});
scenario('categorizeOp: outgoing BLURT to random user → blurt_sent', () => {
const r = categorizeOp(transferOp({ from: 'me', to: 'bob', amount: '5.000 BLURT' }), 'me', PREDS);
if (!r) throw new Error('expected row');
if (r.category !== 'blurt_sent') throw new Error(`got ${r.category}`);
if (r.blurtSigned !== -5) throw new Error(`amt ${r.blurtSigned}`);
if (r.counterparty !== 'bob') throw new Error('counterparty');
});
scenario('categorizeOp: outgoing to fees account → order_fee', () => {
const r = categorizeOp(
transferOp({
from: 'me',
to: 'morphit-fees',
amount: '0.500 BLURT',
memo: 'morphit-order:abcd'
}),
'me',
PREDS
);
if (!r) throw new Error('expected row');
if (r.category !== 'order_fee') throw new Error(`got ${r.category}`);
if (r.blurtSigned !== -0.5) throw new Error(`amt ${r.blurtSigned}`);
});
scenario('categorizeOp: outgoing to operator with featured-bid memo → featured_bid', () => {
const r = categorizeOp(
transferOp({ from: 'me', to: 'morphit', amount: '1.234 BLURT', memo: 'featured-bid:slot-3' }),
'me',
PREDS
);
if (!r) throw new Error('expected row');
if (r.category !== 'featured_bid') throw new Error(`got ${r.category}`);
});
scenario('categorizeOp: outgoing to operator WITHOUT featured-bid memo → blurt_sent', () => {
const r = categorizeOp(
transferOp({ from: 'me', to: 'morphit', amount: '1.000 BLURT', memo: 'donation' }),
'me',
PREDS
);
if (!r) throw new Error('expected row');
if (r.category !== 'blurt_sent') throw new Error(`got ${r.category}`);
});
scenario('categorizeOp: incoming from operator → featured_payout', () => {
const r = categorizeOp(
transferOp({ from: 'morphit', to: 'me', amount: '50.000 BLURT' }),
'me',
PREDS
);
if (!r) throw new Error('expected row');
if (r.category !== 'featured_payout') throw new Error(`got ${r.category}`);
});
scenario('categorizeOp: non-transfer op → null', () => {
const r = categorizeOp(
{ block: 1, trx_id: 'x', timestamp: '2024-01-01T00:00:00', op: ['vote', {}] },
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
scenario('categorizeOp: non-BLURT amount → null', () => {
const r = categorizeOp(
transferOp({ from: 'alice', to: 'me', amount: '10.000 BBD' }),
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
scenario('categorizeOp: unrelated transfer (we are neither sender nor receiver) → null', () => {
const r = categorizeOp(
transferOp({ from: 'alice', to: 'bob', amount: '1.000 BLURT' }),
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
scenario('categorizeOp: zero amount → null', () => {
const r = categorizeOp(
transferOp({ from: 'alice', to: 'me', amount: '0.000 BLURT' }),
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
scenario('categorizeOp: timestamp normalized to Z', () => {
const r = categorizeOp(
transferOp({
from: 'alice',
to: 'me',
amount: '1.000 BLURT',
timestamp: '2024-08-15T14:32:18'
}),
'me',
PREDS
);
if (!r) throw new Error('expected row');
if (r.timestamp !== '2024-08-15T14:32:18Z') throw new Error(`got ${r.timestamp}`);
});
scenario('categorizeOp: WIF-shaped substring in memo gets redacted', () => {
const wif = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3';
const r = categorizeOp(
transferOp({
from: 'alice',
to: 'me',
amount: '1.000 BLURT',
memo: `payment ref ${wif} attached`
}),
'me',
PREDS
);
if (!r) throw new Error('expected row');
if (r.memo.includes(wif)) throw new Error('WIF survived redaction');
if (!r.memo.includes('[REDACTED-WIF-SHAPE]')) throw new Error('redaction marker missing');
});
scenario('categorizeOp: malformed transfer body → null', () => {
const r = categorizeOp(
{
block: 1,
trx_id: 'x',
timestamp: '2024-01-01T00:00:00',
op: ['transfer', { from: 'alice' }]
},
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
// ─── Audit fix #7: defensive op.op shape guards ─────────────────────
scenario('categorizeOp: op.op is null → null (no crash)', () => {
const r = categorizeOp(
{
block: 1,
trx_id: 'x',
timestamp: '2024-01-01T00:00:00',
op: null as unknown as [string, Record<string, unknown>]
},
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
scenario('categorizeOp: op.op is empty array → null', () => {
const r = categorizeOp(
{
block: 1,
trx_id: 'x',
timestamp: '2024-01-01T00:00:00',
op: [] as unknown as [string, Record<string, unknown>]
},
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
scenario('categorizeOp: op.op[1] is not an object → null', () => {
const r = categorizeOp(
{
block: 1,
trx_id: 'x',
timestamp: '2024-01-01T00:00:00',
op: ['transfer', null as unknown as Record<string, unknown>]
},
'me',
PREDS
);
if (r !== null) throw new Error('expected null');
});
// ─── filterByDateRange ──────────────────────────────────────────────
scenario('filterByDateRange: inclusive on both ends', () => {
const rows: PnlRow[] = [
{
timestamp: '2024-01-01T00:00:00Z',
trxId: 'a',
block: 1,
category: 'blurt_received',
counterparty: 'x',
blurtSigned: 1,
memo: ''
},
{
timestamp: '2024-06-15T12:00:00Z',
trxId: 'b',
block: 2,
category: 'blurt_received',
counterparty: 'x',
blurtSigned: 1,
memo: ''
},
{
timestamp: '2024-12-31T23:59:59Z',
trxId: 'c',
block: 3,
category: 'blurt_received',
counterparty: 'x',
blurtSigned: 1,
memo: ''
}
];
const start = Math.floor(Date.parse('2024-01-01T00:00:00Z') / 1000);
const end = Math.floor(Date.parse('2024-12-31T23:59:59Z') / 1000);
const filtered = filterByDateRange(rows, start, end);
if (filtered.length !== 3) throw new Error(`got ${filtered.length}`);
});
scenario('filterByDateRange: excludes outside range', () => {
const rows: PnlRow[] = [
{
timestamp: '2023-12-31T23:59:59Z',
trxId: 'a',
block: 1,
category: 'blurt_received',
counterparty: 'x',
blurtSigned: 1,
memo: ''
},
{
timestamp: '2024-06-15T12:00:00Z',
trxId: 'b',
block: 2,
category: 'blurt_received',
counterparty: 'x',
blurtSigned: 1,
memo: ''
},
{
timestamp: '2025-01-01T00:00:01Z',
trxId: 'c',
block: 3,
category: 'blurt_received',
counterparty: 'x',
blurtSigned: 1,
memo: ''
}
];
const start = Math.floor(Date.parse('2024-01-01T00:00:00Z') / 1000);
const end = Math.floor(Date.parse('2024-12-31T23:59:59Z') / 1000);
const filtered = filterByDateRange(rows, start, end);
if (filtered.length !== 1) throw new Error(`got ${filtered.length}`);
if (filtered[0]!.trxId !== 'b') throw new Error('wrong row');
});
// ─── buildPnlCsv ────────────────────────────────────────────────────
const HEADERS: CsvHeaders = {
timestamp: 'Timestamp (UTC)',
category: 'Category',
counterparty: 'Counterparty',
blurtAmount: 'Net BLURT change',
memo: 'Memo',
trxId: 'Transaction ID',
block: 'Block'
};
const LABELS: CategoryLabels = {
blurt_received: 'BLURT received',
blurt_sent: 'BLURT sent',
order_fee: 'Order fee',
featured_bid: 'Featured bid',
featured_payout: 'Featured payout'
};
function makeRow(overrides: Partial<PnlRow> = {}): PnlRow {
return {
timestamp: '2024-08-15T14:32:18Z',
trxId: 'abc123',
block: 100_000,
category: 'blurt_received',
counterparty: 'alice',
blurtSigned: 10,
memo: '',
...overrides
};
}
scenario('buildPnlCsv: starts with UTF-8 BOM', () => {
const csv = buildPnlCsv([makeRow()], HEADERS, LABELS);
if (csv.charCodeAt(0) !== 0xfeff) throw new Error('no BOM');
});
scenario('buildPnlCsv: header line + one row, CRLF separated', () => {
const csv = buildPnlCsv([makeRow()], HEADERS, LABELS);
const lines = csv.slice(1).split('\r\n');
// Trailing \r\n produces an empty final element.
if (lines.length !== 3) throw new Error(`expected 3 (hdr+row+trailing), got ${lines.length}`);
if (!lines[0]!.startsWith('Timestamp (UTC)')) throw new Error('header');
if (!lines[1]!.startsWith('2024-08-15T14:32:18Z')) throw new Error('row');
});
scenario('buildPnlCsv: numeric column unquoted, signed', () => {
const csv = buildPnlCsv(
[makeRow({ blurtSigned: -5.5 }), makeRow({ blurtSigned: 10 })],
HEADERS,
LABELS
);
const lines = csv.slice(1).split('\r\n');
// row 1: blurtSigned column (4th col) should be -5.500
if (!lines[1]!.includes(',-5.500,')) throw new Error('row1 amt');
if (!lines[2]!.includes(',10.000,')) throw new Error('row2 amt');
});
scenario('buildPnlCsv: memo with comma is quoted', () => {
const csv = buildPnlCsv([makeRow({ memo: 'hello, world' })], HEADERS, LABELS);
if (!csv.includes('"hello, world"')) throw new Error('not quoted');
});
scenario('buildPnlCsv: memo with quote is escaped', () => {
const csv = buildPnlCsv([makeRow({ memo: 'she said "hi"' })], HEADERS, LABELS);
if (!csv.includes('"she said ""hi"""')) throw new Error('not escaped');
});
scenario('buildPnlCsv: memo starting with = is CSV-injection-prefixed', () => {
const csv = buildPnlCsv([makeRow({ memo: '=cmd|"calc"' })], HEADERS, LABELS);
// Should be quoted AND prefixed with single-quote.
if (!csv.includes('"\'=cmd|""calc"""')) {
throw new Error('CSV-injection prefix missing or wrong');
}
});
scenario('buildPnlCsv: memo starting with @ is CSV-injection-prefixed', () => {
const csv = buildPnlCsv([makeRow({ memo: '@SUM(A1:A2)' })], HEADERS, LABELS);
if (!csv.includes('"\'@SUM(A1:A2)"')) throw new Error('@ prefix missing');
});
scenario('buildPnlCsv: counterparty starting with - is CSV-injection-prefixed', () => {
const csv = buildPnlCsv([makeRow({ counterparty: '-2-cmd' })], HEADERS, LABELS);
if (!csv.includes('"\'-2-cmd"')) throw new Error('- prefix missing');
});
scenario('buildPnlCsv: unicode in memo preserved', () => {
const csv = buildPnlCsv([makeRow({ memo: 'café — résumé' })], HEADERS, LABELS);
if (!csv.includes('café — résumé')) throw new Error('unicode mangled');
});
scenario('buildPnlCsv: empty rows produce header-only', () => {
const csv = buildPnlCsv([], HEADERS, LABELS);
const lines = csv.slice(1).split('\r\n');
if (lines.length !== 2) throw new Error(`expected hdr + trailing, got ${lines.length}`);
});
scenario('buildPnlCsv: localized category labels in output', () => {
const csv = buildPnlCsv([makeRow({ category: 'order_fee' })], HEADERS, {
...LABELS,
order_fee: 'Frais de commande'
});
if (!csv.includes('Frais de commande')) throw new Error('label not applied');
});
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);
}