Files
wownerogue/test/payment_flow.integration.test.js
T
jwinterm 3e6e3b858b security: fix underpayment exploit and make provably-fair real
Phase 0.1 — Underpayment confirmation bug (CRITICAL):
Payment confirmation gated on `status.confirmed`, which checkPaymentStatus
sets for ANY incoming tx regardless of amount, so sending a single atomic
unit granted a full game/credits. Now gate on `status.complete` (received
>= expected). Underpayers are warned once and the payment stays pending so
a top-up can complete it. Persist `received_amount` for single-game and
credits confirmations for reconciliation. Adds an underpayment regression
test.

Phase 0.2 — Provably-fair was decorative (CRITICAL):
The committed seed had zero effect on the dungeon (created seededRNG was
unused; generation used Math.random + a globally-seeded ROT.RNG). Thread a
per-game seeded RNG derived from the committed seed through dungeon
generation (reseed the Digger via seedToInt + replace all Math.random) and
monster movement (seededShuffle). Add DungeonGenerator.regenerateFromSeed
and layoutFingerprint; /api/verify now regenerates the dungeon from the
seed and returns a fingerprint a player can independently reproduce. Adds a
determinism test suite.

Full suite: 11 suites / 113 tests passing.

Plan and progress tracked in docs/REMEDIATION_PLAN.md.
2026-05-31 09:14:21 -04:00

150 lines
5.9 KiB
JavaScript

const QueueManager = require('../src/network/queueManager');
const PaymentHandlers = require('../src/network/paymentHandlers');
/**
* Integration-style tests for the payment confirmation flow.
*
* 1. A payment that confirms AFTER a block event (entry still unconfirmed) results
* in an immediate game start without waiting for the next block.
* 2. (Phase 0.1 security gate) A payment that is confirmed but UNDERPAID
* (received < expected) must NOT start a game or grant anything.
*/
const blockHeight = 100;
const socketId = 'sock-int';
/**
* Build a fresh PaymentHandlers + QueueManager harness with controllable wallet
* monitoring. Returns the pieces the tests need.
*/
function buildHarness() {
const events = [];
const activeGames = new Map();
const io = {
to: () => ({ emit: (event, data) => events.push({ event, data }) })
};
const broadcastManager = {
sendStatusUpdate: (id, type, message) => events.push({ event: 'status', data: { type, message } }),
broadcastChatMessage: () => {}
};
const debugManager = {
CONSOLE_LOGGING: false,
getCurrentBlockHeight: () => blockHeight
};
const userObj = { id: socketId, clientId: 'client-int', payout_address: 'wow1address' };
const getUserBySocket = (id) => (id === socketId ? userObj : null);
const createGameForUser = (user) => {
const game = {
getState: () => ({ player: { x: 0, y: 0 }, lighting: {}, torches: [], started: true })
};
activeGames.set(user.id, game);
return game;
};
const gameModeManager = {
gameMode: 'PAID_SINGLE',
cryptoType: 'WOW',
currencyDecimals: 11,
singleGamePrice: 1000,
creditsPayoutEnabled: false,
formatAtomicHuman: () => '0.001',
getOrCreateUser: async () => ({ id: socketId, payout_address: 'wow1address', clientId: 'client-int' }),
createPaymentRequest: async () => ({
id: 'pay-1',
address: 'addr-1',
amount: 1000,
amountFormatted: '0.001',
expiresAt: new Date(Date.now() + 60000)
}),
processGameStart: async () => ({ success: true, effectiveMode: 'PAID_SINGLE' }),
db: {
query: async (sql) => {
if (sql && sql.includes('UPDATE payments') && sql.includes('RETURNING')) {
return { rows: [{ id: 'pay-1' }] };
}
return { rows: [] };
}
}
};
let monitorCallback;
const walletService = {
startPaymentMonitoring: (address, cb) => { monitorCallback = cb; },
stopPaymentMonitoring: () => {}
};
const queueManager = new QueueManager({
debugManager, broadcastManager, io, createGameForUser, getUserBySocket,
activeGames, gameModeManager, consoleLogging: false
});
const paymentHandlers = new PaymentHandlers({
io, gameModeManager, walletService, debugManager, queueManager, broadcastManager
});
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
return {
events, queueManager, paymentHandlers, fakeSocket,
getMonitorCallback: () => monitorCallback
};
}
describe('Payment flow integration: confirm-after-block immediate start', () => {
test('unconfirmed at block tick then confirmed triggers immediate game', async () => {
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
const monitorCallback = getMonitorCallback();
expect(monitorCallback).toBeDefined();
// Simulate mempool detection first
await monitorCallback({ in_mempool: true, confirmed: false, amount: 1000, confirmations: 0 });
expect(queueManager.getQueueLength()).toBe(1);
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
// Block tick BEFORE confirmation (should skip unconfirmed)
await queueManager.startGamesForWaiting(blockHeight);
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
expect(queueManager.getQueueLength()).toBe(1);
// Confirmation after the block. `complete: true` means the received amount covers
// the expected amount — required for a game to start (Phase 0.1 gate).
await monitorCallback({ in_mempool: true, confirmed: true, complete: true, amount: 1000, required: 1000, confirmations: 1 });
const gameStarts = events.filter(e => e.event === 'game_start');
expect(gameStarts.length).toBe(1);
expect(gameStarts[0].data.blockHeight).toBe(blockHeight);
expect(queueManager.getQueueLength()).toBe(0);
});
test('confirmed but UNDERPAID does not start a game or grant anything', async () => {
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
const monitorCallback = getMonitorCallback();
expect(monitorCallback).toBeDefined();
// Mempool detection queues the player (queueing is gated on confirmation downstream)
await monitorCallback({ in_mempool: true, confirmed: false, amount: 1, confirmations: 0 });
// Confirmed on-chain, but only 1 of 1000 atomic units received → complete is false.
await monitorCallback({ in_mempool: true, confirmed: true, complete: false, amount: 1, required: 1000, confirmations: 1 });
// No game must start, and the user must be warned about the underpayment.
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
expect(events.filter(e => e.event === 'payment_underpaid').length).toBe(1);
const underpaid = events.find(e => e.event === 'payment_underpaid');
expect(underpaid.data.received).toBe(1);
expect(underpaid.data.required).toBe(1000);
expect(underpaid.data.shortfall).toBe(999);
// Repeated underpaid polls must not spam the user (notify once).
await monitorCallback({ in_mempool: true, confirmed: true, complete: false, amount: 1, required: 1000, confirmations: 2 });
expect(events.filter(e => e.event === 'payment_underpaid').length).toBe(1);
});
});