diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md new file mode 100644 index 0000000..c79c495 --- /dev/null +++ b/docs/REMEDIATION_PLAN.md @@ -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._ diff --git a/src/game/dungeon.js b/src/game/dungeon.js index 536f409..dd35923 100644 --- a/src/game/dungeon.js +++ b/src/game/dungeon.js @@ -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; diff --git a/src/game/game.js b/src/game/game.js index 5c51b48..3bd4b3e 100644 --- a/src/game/game.js +++ b/src/game/game.js @@ -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); } } diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 3142a76..71e0872 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -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 diff --git a/src/game/monster.js b/src/game/monster.js index e7e24d9..e6022fc 100644 --- a/src/game/monster.js +++ b/src/game/monster.js @@ -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; diff --git a/src/game/provablyFair.js b/src/game/provablyFair.js index c733180..1a0f62a 100644 --- a/src/game/provablyFair.js +++ b/src/game/provablyFair.js @@ -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, diff --git a/src/index.js b/src/index.js index a7036cd..b9a541e 100644 --- a/src/index.js +++ b/src/index.js @@ -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); }); diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 37d82e0..90f37bd 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -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}`); } diff --git a/test/payment_flow.integration.test.js b/test/payment_flow.integration.test.js index 840850c..7fde186 100644 --- a/test/payment_flow.integration.test.js +++ b/test/payment_flow.integration.test.js @@ -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); + }); }); diff --git a/test/provablyFair.test.js b/test/provablyFair.test.js new file mode 100644 index 0000000..4eccf4c --- /dev/null +++ b/test/provablyFair.test.js @@ -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)); + }); +});