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.
This commit is contained in:
jwinterm
2026-05-31 09:14:21 -04:00
parent 82fdcc0203
commit 3e6e3b858b
10 changed files with 468 additions and 148 deletions
+80
View File
@@ -0,0 +1,80 @@
# Wownerogue Remediation & Cleanup Plan
> Derived from the deep code review (May 2026). Goal: fix every BAD/UGLY finding, remove
> the spaghetti, and reach a **real-funds launch gate**. Phases are ordered so launch-blockers
> get surgical fixes first, then correctness hardening, then structural cleanup, then a
> test/ops overhaul to lock it in.
## Guiding principles
1. **Stabilize before restructuring.** Fix exploitable money bugs as minimal patches, ship, *then* refactor.
2. **Single source of truth** for the three things that bite: money (one BigInt module), identity (one `user_id`), randomness (one per-game seeded RNG).
3. **Defense in depth.** Every check at the DB *and* app level; every render escapes *and* the server escapes.
4. **Every change ships with a test that fails before and passes after.**
Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker · 🟠 pre-launch · 🟡 post-launch
---
## Phase 0 — Stop the bleeding (launch-blockers)
- [x] **0.1 🔴 S — Verify amount received ≥ expected before confirming.** ✅ Gate on `status.complete` in `paymentHandlers.js`; underpaid → stay pending + `payment_underpaid` warning (once). `received_amount` now recorded for single-game + credits. Regression test added (`payment_flow.integration.test.js`). _113 tests green._
- [x] **0.2 🔴 M — Make provably-fair real.** ✅ Per-game seeded RNG (`seedToInt`) threaded through `dungeon.js` (Digger reseed + all `Math.random()` → `rng`), `monster.js` (`seededShuffle`), `game.js` (chase + generation). `DungeonGenerator.regenerateFromSeed` + `layoutFingerprint`; `/api/verify` regenerates & returns fingerprint. Determinism test suite added (`provablyFair.test.js`). _Verifier can now reproduce the exact dungeon from the seed._
- [ ] **0.3 🔴 M — Fix batch-payout `tx_hash` collision + double-pay.** Stop writing one `tx_hash` to every row (`src/game/gameModeManager.js:1306`). Use per-destination hashes or a `batch_id`; ensure on-chain rows are never re-sent by retry. *Test: 3-dest batch → all completed, retry finds nothing.*
- [ ] **0.4 🔴 S — Harden identity transport.** Move `anon_token` from query string to handshake `auth` (`src/network/sessionManager.js:24`); call `rotateToken` on resume.
- [ ] **0.5 🟠 S — Single-game disconnect recovery.** Extend `recoverPendingPayments` (`src/network/sessionManager.js:158`) to handle `single_game`; stop silently deleting in-progress paid games (`src/network/socketHandlers.js:787`).
- [ ] **0.6 🟠 S — Client + admin XSS and CSP.** Escape chat/admin fields (`html/js/network/socketHandlers.js:291,323`; `html/admin.html`); add CSP headers.
## Phase 1 — Money correctness hardening (🟠)
- [ ] **1.1 M — `src/money/atomic.js` BigInt module** as the only money type; remove `Number()` casts in `gameModeManager.js` and float `reduce` in `walletRPCService.js:314`.
- [ ] **1.2 S — Snapshot payout config at game start** (`gameModeManager.js:237`); pay from recorded multiplier/base.
- [ ] **1.3 S — Close payout uniqueness holes.** Cover `processing` in the unique index; guard INSERT (`gameModeManager.js:1161`); add status/outcome/mode enum CHECKs.
- [ ] **1.4 S — Fix retry stat guard + non-atomic tx_hash write** (`src/payments/payoutRetryService.js:121,174`).
- [ ] **1.5 S — Remove dead refund send-path** (`src/index.js:795`).
- [ ] **1.6 S — `credits` → BIGINT; make migration 010 idempotent** (`src/migrations/010_security_constraints.sql`); per-migration transactions in runner (`src/db/databaseManager.js:146`).
## Phase 2 — Identity, abuse & concurrency (🟠)
- [ ] **2.1 L — Unify identity on `user_id`.** One `IdentityService.resolve(socket)`; kill `getOrCreateUser(socketId)` keying (`gameModeManager.js:926`); key `activeGames` by `user_id`.
- [ ] **2.2 M — Reconnect-proof rate limiting.** Key on `user_id`/IP; IP-limit chat; set `trust proxy`; de-dup limit configs (`src/network/rateLimiter.js:80`).
- [ ] **2.3 M — Input validation + leak fixes.** Validate socket payloads (`connectionHandler.js:90`); TTL-evict `mempoolNotified` (`socketHandlers.js:223`); evict `sessions` on disconnect; wire all `dispose()` into `shutdown()`; snapshot in `checkGamesTimeout` (`socketHandlers.js:1012`).
## Phase 3 — Game integrity (🟠)
- [ ] **3.1 S — Uniform time budget** (elapsed/fixed-block, not "next block"); fix early-entry (`src/network/queueHandler.js:241`).
- [ ] **3.2 S — Reachability guarantee** entrance→treasure→exit (`src/game/dungeon.js`).
- [ ] **3.3 S — Swap/passthrough collision fix** (`src/game/game.js:181`).
## Phase 4 — De-spaghetti / structural cleanup (🟡)
- [ ] **4.1 L — Break up `index.js`** into `app.js`, `routes/{public,user,admin,auth}.js`, `views/verify.js`, `server.js`.
- [ ] **4.2 M — Kill duplicates.** Merge `rpcService`+`rpccalls`; delete dead `handlePlayerMove` (`socketHandlers.js:244`); unify `payout_address` widths; remove `QueryValidator` theater (`databaseManager.js:21`).
- [ ] **4.3 M — Config as immutable snapshots;** stop mutating `process.env` (`gameModeManager.js:189`).
- [ ] **4.4 M — Frontend de-spaghetti.** Shared `escapeHtml`/`dom.js`; kill double-declares + dead `_appendMessage`; explicit init ordering; upgrade/drop jQuery; gate broadcast logging (`broadcastManager.js:33`).
- [ ] **4.5 S — Delete stale `test/` scratch files.**
## Phase 5 — Test & ops overhaul (🟠 — the launch gate)
- [ ] **5.1 L — Real money-path tests** against Postgres (no stubbed `withTransaction`): under/overpay, double-confirm, batch dispatch, credit race, retry idempotency; property-test `money/atomic.js`.
- [ ] **5.2 S — Run real `wownero-wallet-rpc` integration test** (open in TODO since day one).
- [ ] **5.3 S — `environmentValidator` hard-stop** (`process.exit(1)`); add admin-key/mainnet/simulated-block checks (`src/index.js:33`).
- [ ] **5.4 S — Reconcile docs** (README vs `.env.example` house odds); document real provably-fair.
- [ ] **5.5 M — Durability:** DB/Redis-backed idempotency + rate limiter.
- [ ] **5.6 S — TLS** via reverse proxy.
---
## Sequencing
Build `money/atomic.js` (1.1) and per-game RNG (0.2) **early** — Phases 1/3/4 depend on them.
```
Phase 0 (days) → freeze features, ship → Phase 1 (+5.1/5.2 in parallel)
→ Phase 2 → Phase 3 → Phase 4 (safe now) → finish Phase 5 → LAUNCH GATE
```
## Launch gate (real funds)
- [ ] Under/overpayment correct + reconciled (0.1)
- [ ] Provably-fair reproduces dungeon, or claim removed (0.2)
- [ ] No double-payout under batch+retry, test-proven (0.3, 1.3, 1.4, 5.1)
- [ ] All money math BigInt, property-tested (1.1)
- [ ] One identity, impersonation closed (0.4, 2.1)
- [ ] XSS closed + CSP (0.6)
- [ ] Real wallet-rpc integration test executed (5.2)
- [ ] Validator hard-stops on misconfig (5.3)
- [ ] TLS in front (5.6)
_Estimate: ~3–4 weeks to launch gate; Phase 0 in days._
+70 -7
View File
@@ -1,5 +1,7 @@
const crypto = require('crypto');
const ROT = require('./rot.js');
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig');
const { createSeededRNG, seedToInt } = require('./provablyFair');
// Environment-based console logging control
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
@@ -58,6 +60,55 @@ class DungeonGenerator {
};
}
/**
* PROVABLY FAIR (Phase 0.2): Reproduce a dungeon purely from its committed seed.
* Mirrors the standard generation path used by Game so a verifier can independently
* regenerate the exact layout a player saw. Assumes standard generation options
* (the difficulty preset for `cryptoType`); games started with custom dungeon
* overrides would need those overrides passed in `gameOptions` to match.
* @param {string} seed - The revealed game seed
* @param {string|null} cryptoType - Currency, selects the difficulty preset
* @param {object} gameOptions - Optional overrides matching the original game
* @returns {object} The regenerated dungeon ({ map, rooms, entrance, exit, treasure, torches })
*/
static regenerateFromSeed(seed, cryptoType = null, gameOptions = {}) {
const dungeonConfig = this.getConfig(cryptoType);
const width = gameOptions.width || dungeonConfig.DEFAULT_WIDTH;
const height = gameOptions.height || dungeonConfig.DEFAULT_HEIGHT;
const gameConfig = {
width,
height,
floorVariation: gameOptions.floorVariation !== undefined ? gameOptions.floorVariation : dungeonConfig.FLOOR_VARIATION,
torchEnabled: gameOptions.torchEnabled !== undefined ? gameOptions.torchEnabled : dungeonConfig.TORCH_ENABLED,
torchDensity: gameOptions.torchDensity !== undefined ? gameOptions.torchDensity : dungeonConfig.TORCH_DENSITY,
primaryFloor: gameOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR,
secondaryFloor: gameOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR,
torchTile: gameOptions.torchTile || dungeonConfig.TORCH_TILE,
...gameOptions
};
return this.generate(width, height, {
...gameConfig,
rng: createSeededRNG(seed),
seedInt: seedToInt(seed)
});
}
/**
* Deterministic fingerprint of a dungeon's layout, used to compare a regenerated
* dungeon against the one that was played.
* @param {object} dungeon - A dungeon object from generate()/regenerateFromSeed()
* @returns {string} SHA-256 hex of the layout-defining fields
*/
static layoutFingerprint(dungeon) {
const payload = JSON.stringify({
map: dungeon.map,
entrance: dungeon.entrance,
exit: dungeon.exit,
treasure: dungeon.treasure
});
return crypto.createHash('sha256').update(payload).digest('hex');
}
static generate(width, height, options = {}) {
const defaultOptions = {
floorVariation: DUNGEON_CONFIGS.FLOOR_VARIATION,
@@ -73,6 +124,17 @@ class DungeonGenerator {
console.log(`[DungeonGenerator] Generating dungeon with effective torchDensity: ${config.torchDensity}`);
}
// PROVABLY FAIR (Phase 0.2): when a per-game seeded RNG is supplied, all
// randomness in generation is derived from the committed seed so the dungeon
// is reproducible for verification. `config.rng` drives floor/torch/treasure
// placement; `config.seedInt` deterministically seeds ROT.RNG for the Digger.
// Generation is fully synchronous, so seeding the global ROT.RNG here is safe
// even with concurrent games (no other game can interleave mid-generation).
const rng = (typeof config.rng === 'function') ? config.rng : Math.random;
if (config.seedInt != null) {
ROT.RNG.setSeed(config.seedInt);
}
// Create dungeon using ROT.js Map.Digger
const digger = new ROT.Map.Digger(width, height, {
roomWidth: DUNGEON_CONFIGS.ROOM_WIDTH_RANGE,
@@ -117,8 +179,8 @@ class DungeonGenerator {
const treasureCenter = treasureRoom.getCenter();
// Place treasure slightly off center to make it more interesting
const treasure = [
treasureCenter[0] + Math.floor(Math.random() * 3) - 1,
treasureCenter[1] + Math.floor(Math.random() * 3) - 1
treasureCenter[0] + Math.floor(rng() * 3) - 1,
treasureCenter[1] + Math.floor(rng() * 3) - 1
];
// Ensure the treasure is on a floor tile (check for both floor types)
@@ -150,7 +212,8 @@ class DungeonGenerator {
}
let placedTorchesCount = 0; // Counter for torches placed in this function
const rng = (typeof config.rng === 'function') ? config.rng : Math.random;
// Log the torchDensity being used for this map generation pass
if (CONSOLE_LOGGING) {
console.log(`[DungeonGenerator.enhanceMapWithVariations] Starting enhancement with torchDensity: ${config.torchDensity}`);
@@ -165,14 +228,14 @@ class DungeonGenerator {
if (cell === 0) {
// Floor tile - randomly choose primary or secondary
if (Math.random() < config.floorVariation) {
if (rng() < config.floorVariation) {
enhancedMap[y][x] = config.secondaryFloor;
} else {
enhancedMap[y][x] = config.primaryFloor;
}
} else if (cell === 1) {
// Wall tile - maybe add a torch
if (config.torchEnabled && this.shouldPlaceTorch(basicMap, x, y, config.torchDensity)) {
if (config.torchEnabled && this.shouldPlaceTorch(basicMap, x, y, config.torchDensity, rng)) {
enhancedMap[y][x] = config.torchTile;
placedTorchesCount++; // Increment if a torch is placed
} else {
@@ -188,7 +251,7 @@ class DungeonGenerator {
}
// Determine if a torch should be placed on this wall tile
static shouldPlaceTorch(map, x, y, torchDensity) {
static shouldPlaceTorch(map, x, y, torchDensity, rng = Math.random) {
// Only place torches on walls that are adjacent to floors (for lighting logic)
const height = map.length;
const width = map[0].length;
@@ -202,7 +265,7 @@ class DungeonGenerator {
return nx >= 0 && nx < width && ny >= 0 && ny < height && map[ny][nx] === 0;
});
const randomValue = Math.random();
const randomValue = rng();
const shouldPlace = adjacentToFloor && randomValue < torchDensity;
return shouldPlace;
+18 -7
View File
@@ -5,7 +5,7 @@ const Monster = require('./monster.js');
const DungeonGenerator = require('./dungeon.js');
const LightingAndFov = require('./lightingAndFov.js');
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig');
const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRNG } = require('./provablyFair');
const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRNG, seedToInt, seededShuffle } = require('./provablyFair');
// Environment-based console logging control
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
@@ -29,7 +29,11 @@ class Game {
// Provably fair: Generate game proof before anything else
this.gameProof = createGameProof(this.id);
// Per-game deterministic RNG derived from the committed seed. ALL game randomness
// (dungeon generation + monster movement) flows through this so the game is
// reproducible from the seed for provably-fair verification.
this.seededRNG = createSeededRNG(this.gameProof.seed);
this.seedInt = seedToInt(this.gameProof.seed);
// Get default configuration from DungeonGenerator
const dungeonConfig = DungeonGenerator.getConfig();
@@ -114,8 +118,14 @@ class Game {
this.monsterMoveAccumulator = 0; // For fractional monster moves
this.fee = 0;
// Generate dungeon with the provided dimensions and config
this.dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, this.gameConfig);
// Generate dungeon with the provided dimensions and config.
// Pass the per-game seeded RNG + numeric seed so the layout is deterministic
// and verifiable from the committed seed (provably fair).
this.dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, {
...this.gameConfig,
rng: this.seededRNG,
seedInt: this.seedInt
});
// Place player at entrance
if (this.dungeon.entrance) {
@@ -236,12 +246,13 @@ class Game {
while (this.monsterMoveAccumulator >= 1) {
this.monsterMoveAccumulator -= 1;
// Apply chase aggressiveness (chance to move randomly instead of chasing)
if (Math.random() < chaseAggressiveness) {
this.monster.moveTowardPlayer(this.player, this.dungeon);
// Apply chase aggressiveness (chance to move randomly instead of chasing).
// Use the per-game seeded RNG so monster behaviour is deterministic from the seed.
if (this.seededRNG() < chaseAggressiveness) {
this.monster.moveTowardPlayer(this.player, this.dungeon, this.seededRNG);
} else {
// Random move - still try to move, just not toward player
this.monster.moveTowardPlayer(null, this.dungeon);
this.monster.moveTowardPlayer(null, this.dungeon, this.seededRNG);
}
}
+4 -3
View File
@@ -260,7 +260,7 @@ class GameModeManager {
* @param {object} packageInfo - Package info (credits, bonus, etc.)
* @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed
*/
async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null) {
async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null) {
try {
// Look up user from payment record's user_id (stable across socket reconnects)
// This avoids the bug where socket ID changes if user refreshes during confirmation
@@ -301,10 +301,11 @@ class GameModeManager {
UPDATE payments
SET status = 'confirmed',
credits_purchased = $1,
confirmed_at = NOW()
confirmed_at = NOW(),
received_amount = COALESCE($3, received_amount)
WHERE id = $2 AND status = 'pending'
RETURNING id
`, [creditsToAdd, paymentId]);
`, [creditsToAdd, paymentId, receivedAmount]);
if (paymentUpdateResult.rows.length === 0) {
// Already processed by another instance - not an error
+12 -10
View File
@@ -1,4 +1,5 @@
const ROT = require('./rot.js');
const { seededShuffle } = require('./provablyFair');
/**
* Monster class - Manages monster state and AI with improved pathfinding
@@ -104,7 +105,7 @@ class Monster {
/**
* Improved AI to move toward player using A* pathfinding with limited vision
*/
moveTowardPlayer(player, dungeon) {
moveTowardPlayer(player, dungeon, rng = Math.random) {
if (!dungeon || !dungeon.map) return;
// Check if a tile is passable (floor, stairs, treasure)
@@ -115,7 +116,7 @@ class Monster {
// If no player target, wander randomly
if (!player) {
this._wander(dungeon, isPassable);
this._wander(dungeon, isPassable, rng);
return;
}
@@ -143,7 +144,7 @@ class Monster {
}
// Fallback: direct move toward player
this._moveDirectToward(player.x, player.y, dungeon, isPassable);
this._moveDirectToward(player.x, player.y, dungeon, isPassable, rng);
} else if (this.lastKnownPlayerX !== null) {
// Player not visible but we have last known position - patrol toward it
@@ -153,7 +154,7 @@ class Monster {
// Reached last known position, clear it and wander
this.lastKnownPlayerX = null;
this.lastKnownPlayerY = null;
this._wander(dungeon, isPassable);
this._wander(dungeon, isPassable, rng);
} else {
// Path toward last known position
const path = this.findPath(this.lastKnownPlayerX, this.lastKnownPlayerY, dungeon, isPassable);
@@ -165,19 +166,19 @@ class Monster {
// Can't reach last known position, clear and wander
this.lastKnownPlayerX = null;
this.lastKnownPlayerY = null;
this._wander(dungeon, isPassable);
this._wander(dungeon, isPassable, rng);
}
}
} else {
// No player info - wander randomly
this._wander(dungeon, isPassable);
this._wander(dungeon, isPassable, rng);
}
}
/**
* Simple direct movement toward a target (fallback)
*/
_moveDirectToward(targetX, targetY, dungeon, isPassable) {
_moveDirectToward(targetX, targetY, dungeon, isPassable, rng = Math.random) {
const dx = Math.sign(targetX - this.x);
const dy = Math.sign(targetY - this.y);
@@ -212,15 +213,16 @@ class Monster {
}
// Blocked in preferred directions, try diagonals (A* backup)
this._wander(dungeon, isPassable);
this._wander(dungeon, isPassable, rng);
}
/**
* Random wandering behavior
*/
_wander(dungeon, isPassable) {
_wander(dungeon, isPassable, rng = Math.random) {
const dirs = [[0,1],[1,0],[0,-1],[-1,0]];
const shuffledDirs = ROT.RNG.shuffle(dirs.slice());
// Deterministic shuffle from the per-game seeded RNG (provably fair).
const shuffledDirs = seededShuffle(rng, dirs.slice());
for (const [dx, dy] of shuffledDirs) {
const nx = this.x + dx;
+18 -5
View File
@@ -79,6 +79,19 @@ function createSeededRNG(seed) {
};
}
/**
* Derive a positive integer from a hex seed, suitable for seeding ROT.RNG.
* 13 hex chars = 52 bits, which stays within Number.MAX_SAFE_INTEGER so the
* value is represented exactly. Always returns >= 1.
* @param {string} seed - The game seed (hex)
* @returns {number} A positive integer derived deterministically from the seed
*/
function seedToInt(seed) {
const hex = String(seed).replace(/[^0-9a-fA-F]/g, '').slice(0, 13) || '1';
const n = parseInt(hex, 16);
return Number.isFinite(n) && n > 0 ? n : 1;
}
/**
* Generate a random integer in range [min, max] using seeded RNG
* @param {function} rng - Seeded RNG function
@@ -158,13 +171,12 @@ function getPostGameReveal(proof, gameResult = {}) {
moves: gameResult.moves ?? 0,
duration: gameResult.duration ?? 0
},
verificationUrl: `/verify/${proof.gameId}`,
verificationUrl: `/verify/${proof.seed}`,
instructions: [
"To verify this game was fair:",
`1. Compute SHA-256 of the seed: ${proof.seed}`,
`2. Verify it matches the commitment: ${proof.commitment}`,
"3. Use the seed to regenerate the dungeon layout",
"4. Confirm the dungeon matches what you played"
`1. Compute SHA-256 of the seed and confirm it equals the commitment: ${proof.commitment}`,
"2. The seed deterministically generates the dungeon layout, treasure, and monster behaviour",
`3. Open ${`/verify/${proof.seed}`} to regenerate the dungeon from the seed and compare its layout fingerprint`
]
};
}
@@ -196,6 +208,7 @@ module.exports = {
hashSeed,
verifySeed,
createSeededRNG,
seedToInt,
seededRandomInt,
seededShuffle,
createGameProof,
+18
View File
@@ -1753,6 +1753,24 @@ app.get('/api/verify', (req, res) => {
}
const result = verifyGame(seed, commitment);
// PROVABLY FAIR (Phase 0.2): when the seed matches the commitment, regenerate the
// dungeon from the seed and return a layout fingerprint so the player can confirm
// the layout is a deterministic function of the committed seed (not crafted after payment).
if (result.valid) {
try {
const DungeonGenerator = require('./game/dungeon');
const dungeon = DungeonGenerator.regenerateFromSeed(seed, process.env.CRYPTO_TYPE || 'WOW');
result.layoutFingerprint = DungeonGenerator.layoutFingerprint(dungeon);
result.dungeonSize = { width: dungeon.map[0].length, height: dungeon.map.length };
result.entrance = dungeon.entrance;
result.exit = dungeon.exit;
result.treasure = dungeon.treasure;
} catch (e) {
// Regeneration is best-effort; the hash check above is the primary verification.
}
}
res.json(result);
});
+38 -3
View File
@@ -16,6 +16,8 @@ class PaymentHandlers {
this.broadcastManager = broadcastManager;
this.sessionManager = sessionManager;
this.mempoolNotified = new Set();
// Track underpaid payments we've already warned about (notify once, keep monitoring for top-up)
this.underpaidNotified = new Set();
this.paymentMonitors = new Map();
// Track pending payment metadata per socket (may reuse existing)
this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt }
@@ -311,6 +313,34 @@ class PaymentHandlers {
socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null });
}
} else if (status.confirmed) {
// SECURITY (Phase 0.1): A confirmed transaction is NOT sufficient to grant a
// game/credits — we must verify the RECEIVED amount covers what was expected.
// checkPaymentStatus() sets `confirmed` for ANY incoming tx on the subaddress,
// while `complete` means totalReceived >= required. Without this gate a player
// could send a single atomic unit and play (or buy credits) for free.
if (!status.complete) {
// Underpaid. Keep monitoring so a later top-up to the same address can
// complete it; warn the user once to avoid spamming on every 2s poll.
if (!this.underpaidNotified.has(paymentRequest.id)) {
this.underpaidNotified.add(paymentRequest.id);
const required = status.required || 0;
const received = status.amount || 0;
const shortfall = Math.max(0, required - received);
console.warn(`[PaymentHandlers] Underpaid payment ${paymentRequest.id}: received ${received}, required ${required} (shortfall ${shortfall})`);
try {
socket.emit('payment_underpaid', {
paymentId: paymentRequest.id,
received,
required,
shortfall
});
} catch (_) {}
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
`⚠️ UNDERPAYMENT DETECTED\n\nReceived less than the required amount. Send the remaining balance to the SAME address to complete your payment, or wait for it to expire and try again.`);
}
return;
}
// SECURITY: Use in-memory Set as fast-path, but DB is source of truth
// This prevents double-processing on server restart
if (this.confirmedPayments.has(paymentRequest.id)) {
@@ -331,7 +361,8 @@ class PaymentHandlers {
const creditsResult = await this.gameModeManager.processCreditsPackageConfirmation(
socket.id,
paymentRequest.id,
mapping.package
mapping.package,
Math.round(status.amount || 0)
);
if (creditsResult.success) {
socket.emit('credits_update', { balance: creditsResult.newBalance });
@@ -368,10 +399,11 @@ class PaymentHandlers {
const updateResult = await this.gameModeManager.db.query(`
UPDATE payments
SET status = 'confirmed',
confirmed_at = NOW()
confirmed_at = NOW(),
received_amount = $2
WHERE id = $1 AND status = 'pending'
RETURNING id
`, [paymentRequest.id]);
`, [paymentRequest.id, Math.round(status.amount || 0)]);
wasUpdated = updateResult.rows.length > 0;
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`[PaymentHandlers] Payment ${paymentRequest.id} status update: ${wasUpdated ? 'success' : 'already confirmed'}`);
@@ -464,6 +496,9 @@ class PaymentHandlers {
if (mapping) {
this.walletService.stopPaymentMonitoring(mapping.address);
this.socketPaymentMap.delete(socketId);
if (mapping.paymentId != null) {
this.underpaidNotified.delete(mapping.paymentId);
}
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🛑 Stopped monitoring for socket ${socketId}`);
}
+122 -113
View File
@@ -2,139 +2,148 @@ const QueueManager = require('../src/network/queueManager');
const PaymentHandlers = require('../src/network/paymentHandlers');
/**
* Integration-style test to ensure a payment that confirms AFTER a block event
* (where the entry was still unconfirmed) results in an immediate game start
* without waiting for the next block.
* 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 = [];
const activeGames = new Map();
const socketId = 'sock-int';
const blockHeight = 100;
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
// Mock IO emitter
const io = {
to: () => ({
emit: (event, data) => {
events.push({ event, data });
}
})
};
// Minimal broadcast manager stub
const broadcastManager = {
sendStatusUpdate: () => {},
broadcastChatMessage: () => {}
};
// Debug manager mock
const debugManager = {
CONSOLE_LOGGING: false,
getCurrentBlockHeight: () => blockHeight
};
// User lookup
const userObj = { id: socketId, clientId: 'client-int', payout_address: 'wow1address' };
const getUserBySocket = (id) => id === socketId ? userObj : null;
// createGameForUser mock (records active game & returns state)
const createGameForUser = (user) => {
const game = {
getState: () => ({ player: { x:0, y:0 }, lighting: {}, torches: [], started: true })
};
activeGames.set(user.id, game);
return game;
};
// Game mode manager mock (paid single game mode)
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)
}),
// Mock processGameStart for QueueManager.startGameImmediately
processGameStart: async () => ({ success: true, effectiveMode: 'PAID_SINGLE' }),
// Mock db for PaymentHandlers DB update on confirmation
// The security fix requires UPDATE to return rows to confirm the payment was updated
db: {
query: async (sql) => {
// If this is the UPDATE payments query, return a row to indicate success
if (sql && sql.includes('UPDATE payments') && sql.includes('RETURNING')) {
return { rows: [{ id: 'pay-1' }] };
}
return { rows: [] };
}
}
};
// Wallet service mock with manual callback control
let monitorCallback;
const walletService = {
startPaymentMonitoring: (address, cb) => { monitorCallback = cb; },
stopPaymentMonitoring: () => {}
};
// Instantiate real QueueManager & PaymentHandlers
const queueManager = new QueueManager({
debugManager,
broadcastManager,
io,
createGameForUser,
getUserBySocket,
activeGames,
gameModeManager,
consoleLogging: false
});
const paymentHandlers = new PaymentHandlers({
io,
gameModeManager,
walletService,
debugManager,
queueManager,
broadcastManager
});
// Fake socket object with id
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
// Create payment request (does NOT queue yet) and start monitoring
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
const monitorCallback = getMonitorCallback();
expect(monitorCallback).toBeDefined();
// Simulate mempool detection first (callback is async, so we need to await it)
// Simulate mempool detection first
await monitorCallback({ in_mempool: true, confirmed: false, amount: 1000, confirmations: 0 });
// Should have queued the player but not started a game
expect(queueManager.getQueueLength()).toBe(1);
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
// Simulate block tick BEFORE confirmation (should skip unconfirmed)
// Block tick BEFORE confirmation (should skip unconfirmed)
await queueManager.startGamesForWaiting(blockHeight);
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
// Entry should still be in queue (re-queued)
expect(queueManager.getQueueLength()).toBe(1);
// Now simulate confirmation after the block (await the async callback)
await monitorCallback({ in_mempool: true, confirmed: true, amount: 1000, confirmations: 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 });
// Immediate game start expected
const gameStarts = events.filter(e => e.event === 'game_start');
expect(gameStarts.length).toBe(1);
expect(gameStarts[0].data.blockHeight).toBe(blockHeight);
// Queue should now be empty
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);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Provably-fair determinism tests (Phase 0.2).
*
* The committed seed must deterministically generate the dungeon and drive monster
* behaviour, so that:
* - the same seed always reproduces the exact same dungeon (verifiability), and
* - the server cannot craft a different/unfair dungeon after seeing the seed.
*/
const DungeonGenerator = require('../src/game/dungeon');
const Monster = require('../src/game/monster');
const { createSeededRNG, hashSeed, verifyGame } = require('../src/game/provablyFair');
const SEED_A = 'a'.repeat(64);
const SEED_B = 'b'.repeat(64);
describe('Provably fair: deterministic dungeon generation', () => {
test('same seed regenerates an identical dungeon layout', () => {
const d1 = DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW');
const d2 = DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW');
expect(DungeonGenerator.layoutFingerprint(d1))
.toBe(DungeonGenerator.layoutFingerprint(d2));
expect(d1.entrance).toEqual(d2.entrance);
expect(d1.exit).toEqual(d2.exit);
expect(d1.treasure).toEqual(d2.treasure);
expect(d1.map).toEqual(d2.map);
});
test('regeneration is stable even when interleaved with another seed (no global RNG bleed)', () => {
const a1 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
// Generate a different seed's dungeon in between — must not affect SEED_A's output.
DungeonGenerator.regenerateFromSeed(SEED_B, 'WOW');
const a2 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
expect(a1).toBe(a2);
});
test('different seeds produce different layouts', () => {
const fpA = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
const fpB = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_B, 'WOW'));
expect(fpA).not.toBe(fpB);
});
test('verifyGame confirms the seed/commitment relationship', () => {
const commitment = hashSeed(SEED_A);
expect(verifyGame(SEED_A, commitment).valid).toBe(true);
expect(verifyGame(SEED_B, commitment).valid).toBe(false);
});
});
describe('Provably fair: deterministic monster movement', () => {
// A small open room so the monster always has somewhere to wander.
function buildDungeon() {
const map = Array.from({ length: 7 }, () => Array(7).fill("'1"));
for (let i = 0; i < 7; i++) { map[0][i] = '#'; map[6][i] = '#'; map[i][0] = '#'; map[i][6] = '#'; }
return { map };
}
test('wandering is reproducible from the same seeded RNG sequence', () => {
const dungeon = buildDungeon();
const run = () => {
const rng = createSeededRNG(SEED_A);
const m = new Monster(3, 3, { visionRange: 5 });
const path = [];
for (let i = 0; i < 20; i++) {
m.moveTowardPlayer(null, dungeon, rng); // no player => wander using rng
path.push([m.x, m.y]);
}
return path;
};
expect(run()).toEqual(run());
});
test('different seeds yield different wander paths', () => {
const dungeon = buildDungeon();
const walk = (seed) => {
const rng = createSeededRNG(seed);
const m = new Monster(3, 3, { visionRange: 5 });
const path = [];
for (let i = 0; i < 20; i++) {
m.moveTowardPlayer(null, dungeon, rng);
path.push([m.x, m.y]);
}
return JSON.stringify(path);
};
expect(walk(SEED_A)).not.toBe(walk(SEED_B));
});
});