Files
wownerogue/test/paymentReceiptEvidence.test.js
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

129 lines
6.1 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const GameModeManager = require('../src/game/gameModeManager');
function chainReceipt(txHash, outputId, addressIndex, amount) {
return {
evidenceType: 'chain_output',
evidenceId: `${txHash}:${outputId}`,
providerId: 'native-monero',
txHash,
outputId,
addressIndex,
amount: String(amount),
confirmed: true
};
}
function receiptDb(rows) {
const payments = new Map(rows.map(row => [row.id, { provider_id: 'native-monero', status: 'pending', ...row }]));
const receipts = [];
const client = {
query: jest.fn(async (sql, params = []) => {
if (/SELECT id, status, expected_amount/i.test(sql)) {
const payment = payments.get(params[0]);
return { rows: payment ? [{ ...payment }] : [] };
}
if (/INSERT INTO payment_receipts/i.test(sql)) {
const candidate = {
payment_id: params[0], provider_id: params[1], evidence_type: params[2],
evidence_id: params[3], tx_hash: params[4], output_id: params[5],
address_index: params[6], amount: params[7]
};
const occupied = receipts.some(receipt => receipt.provider_id === candidate.provider_id
&& receipt.evidence_id === candidate.evidence_id);
if (!occupied) receipts.push(candidate);
return { rows: [], rowCount: occupied ? 0 : 1 };
}
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
const owned = receipts.filter(receipt => receipt.payment_id === params[0]
&& receipt.provider_id === params[1]);
const total = owned.reduce((sum, receipt) => sum + BigInt(receipt.amount), 0n);
const hashes = owned.map(receipt => receipt.tx_hash).filter(Boolean).sort();
return { rows: [{ total: total.toString(), primary_tx_hash: hashes[0] || null }] };
}
if (/UPDATE payments/i.test(sql)) {
const payment = payments.get(params[0]);
if (!payment || payment.status !== 'pending') return { rows: [] };
payment.status = 'confirmed';
payment.received_amount = params[1];
payment.tx_hash = params[2];
return { rows: [{ id: payment.id }] };
}
return { rows: [], rowCount: 0 };
})
};
return {
withTransaction: jest.fn(async callback => callback(client)),
payments,
receipts
};
}
describe('durable incoming payment receipts', () => {
test('one Monero transaction can confirm distinct invoice subaddresses, not the same output twice', async () => {
const hash = 'a'.repeat(64);
const outputOne = '1'.repeat(64);
const outputTwo = '2'.repeat(64);
const db = receiptDb([
{ id: 1, expected_amount: '100', address_index: 5 },
{ id: 2, expected_amount: '100', address_index: 6 },
{ id: 3, expected_amount: '100', address_index: 5 }
]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
await expect(manager.confirmSingleGamePayment(1, '100', [chainReceipt(hash, outputOne, 5, 100)]))
.resolves.toEqual({ updated: true });
await expect(manager.confirmSingleGamePayment(2, '100', [chainReceipt(hash, outputTwo, 6, 100)]))
.resolves.toEqual({ updated: true });
await expect(manager.confirmSingleGamePayment(3, '100', [chainReceipt(hash, outputOne, 5, 100)]))
.rejects.toThrow(/do not cover/i);
expect(db.payments.get(1).status).toBe('confirmed');
expect(db.payments.get(2).status).toBe('confirmed');
expect(db.payments.get(3).status).toBe('pending');
expect(db.receipts).toHaveLength(2);
});
test('several confirmed top-ups can exactly cover one invoice', async () => {
const db = receiptDb([{ id: 4, expected_amount: '100', address_index: 9 }]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
const result = await manager.confirmSingleGamePayment(4, '100', [
chainReceipt('b'.repeat(64), '3'.repeat(64), 9, 40),
chainReceipt('c'.repeat(64), '4'.repeat(64), 9, 60)
]);
expect(result.updated).toBe(true);
expect(db.receipts).toHaveLength(2);
expect(db.payments.get(4).received_amount).toBe('100');
});
test('distinct outputs in one transaction to one subaddress sum once despite replay', async () => {
const hash = 'd'.repeat(64);
const first = chainReceipt(hash, '5'.repeat(64), 12, 40);
const second = chainReceipt(hash, '6'.repeat(64), 12, 60);
const db = receiptDb([{ id: 5, expected_amount: '100', address_index: 12 }]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
const result = await manager.confirmSingleGamePayment(5, '140', [first, second, { ...first }]);
expect(result).toEqual({ updated: true });
expect(db.receipts).toHaveLength(2);
expect(db.payments.get(5).received_amount).toBe('100');
});
test('migration removes txid-only uniqueness and installs the output-scoped forward guard', () => {
const sql = fs.readFileSync(path.join(__dirname, '../src/migrations/037_payment_receipt_evidence.sql'), 'utf8');
expect(sql).toMatch(/DROP INDEX IF EXISTS idx_payments_tx_hash_unique/i);
expect(sql).not.toMatch(/CREATE\s+UNIQUE\s+INDEX[\s\S]*?ON\s+payments\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
expect(sql).toMatch(/ON\s+payment_receipts\s*\(\s*provider_id\s*,\s*evidence_id\s*\)/i);
expect(sql).not.toMatch(/ON\s+payment_receipts\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
expect(sql).not.toMatch(/UNIQUE\s*\(subaddress\)/i);
expect(sql).toMatch(/evidence_id\s*=\s*tx_hash\s*\|\|\s*':'\s*\|\|\s*output_id/i);
expect(sql).toMatch(/OLD\.status IS DISTINCT FROM 'confirmed'/i);
expect(sql).toMatch(/receipt_total < NEW\.expected_amount/i);
});
});