diff --git a/TODO.md b/TODO.md index 8fe766f..4aaffb5 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ ## High Priority - [x] **Verify Server Startup**: Server starts cleanly with `npm run dev` ✅ - [x] **Test Payment Flow**: Fixed critical bug where payment confirmation wasn't updating DB status ✅ -- [ ] **Test Payouts**: Confirm payouts are triggered correctly upon winning in paid modes. +- [x] **Test Payouts**: Payout flow verified with duplicate prevention and proper mode tracking ✅ - [x] **Address Persistence**: Verify that payout addresses persist across server restarts via session tokens. ✅ - [x] **Mixed Mode Logic**: Per-user mode determination based on credits balance ✅ - [x] **Credits Package Bug**: Credits correctly parsed from package info on confirmation ✅ @@ -14,7 +14,11 @@ ## Recent Fixes (Jan 2026) - [x] **Payment DB Status Bug**: Fixed critical issue where single_game payments weren't marked 'confirmed' in DB, causing game start to fail ✅ - [x] **TileMap Character Error**: Fixed '●' character not in tileMap causing tile mode rendering failures ✅ -- [x] **Test Suite Fixes**: All 47 tests now pass, including security tests with mock DB fallback ✅ +- [x] **Test Suite Fixes**: All 106 tests now pass (10 test suites) ✅ +- [x] **Credit Deduction Bug**: Fixed race condition where credits could go negative - now uses `WHERE credits >= $1` ✅ +- [x] **Payout Mode Bug**: Fixed `completeGame` using global `gameMode` instead of recorded `payment_mode` from game record ✅ +- [x] **Duplicate Payout Prevention**: Added check for existing payout before processing to prevent double-payouts ✅ +- [x] **Dead Code Cleanup**: Removed unused MoneroPayService (was never used, WalletRPCService is the actual service) ✅ ## New Features (Just Added) - [x] **Chat History Persistence**: `ChatHistoryManager` stores messages in PostgreSQL with 30-day retention ✅ @@ -47,7 +51,7 @@ - [x] **Deployment Guide**: README includes production deployment with Nginx/Caddy configs and systemd ✅ ## Pre-Beta Checklist -- [x] All 47 tests pass (9 test suites) ✅ +- [x] All 106 tests pass (10 test suites) ✅ - [x] Health check endpoint available at `/health` ✅ - [x] REST endpoints implemented: `/api/user/:socketId/credits`, `/api/user/:socketId/mode`, `/api/user/:socketId/address` ✅ - [x] Payout eligibility shown per payment mode in UI ✅ diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index d3c9eb7..a8fca45 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -20,7 +20,7 @@ const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000; class GameModeManager { constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null) { this.db = databaseManager; - this.walletService = walletRPCService; // Changed from moneroPayService + this.walletService = walletRPCService; this.debugManager = debugManager; this.paymentConfigManager = paymentConfigManager || null; @@ -699,14 +699,25 @@ class GameModeManager { async _processGameStartWithCredits(user, socketId, gameId) { const creditsToSpend = this.creditsPerGameCost; + // Use a conditional update to prevent credits going negative const updateRes = await this.db.query(` UPDATE users SET credits = credits - $1, total_games_played = total_games_played + 1, updated_at = NOW() - WHERE id = $2 + WHERE id = $2 AND credits >= $1 RETURNING credits `, [creditsToSpend, user.id]); + + // If no rows were updated, user doesn't have enough credits + if (updateRes.rows.length === 0) { + return { + success: false, + reason: 'Insufficient credits', + creditsRequired: creditsToSpend, + creditsAvailable: user.credits + }; + } const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend); // Record the game mode used @@ -1137,8 +1148,30 @@ class GameModeManager { } try { - // Update/record game completion in DB (simplified; reuse processGameCompletion if schema matches) - // If a games table exists with at least id & user reference, mark completion. + // Retrieve the game record to get the actual payment_mode used at game start + let recordedPaymentMode = this.gameMode; // fallback to global + let gameDbId = null; + try { + const gameRecord = await this.db.query(` + SELECT id, payment_mode FROM games + WHERE dungeon_seed = $1 AND socket_id = $2 + LIMIT 1 + `, [gameId, socketId]); + if (gameRecord.rows.length > 0) { + gameDbId = gameRecord.rows[0].id; + recordedPaymentMode = (gameRecord.rows[0].payment_mode || this.gameMode).toUpperCase(); + // Normalize to expected values + if (recordedPaymentMode === 'DIRECT') recordedPaymentMode = 'PAID_SINGLE'; + if (recordedPaymentMode === 'CREDITS') recordedPaymentMode = 'PAID_CREDITS'; + } + } catch (e) { + // Non-fatal, use fallback + if (process.env.NODE_ENV === 'development') { + console.warn('Game record lookup warning:', e.message); + } + } + + // Update/record game completion in DB try { await this.db.query(` UPDATE games SET status = $1, treasure_found = $2, moves_made = COALESCE($3, moves_made), duration_seconds = COALESCE($4, duration_seconds), completed_at = NOW() @@ -1158,10 +1191,21 @@ class GameModeManager { } } - // Handle payouts only in PAID_SINGLE mode and only on win (escaped) - const payoutEligibleStartMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); + // Handle payouts based on the actual recorded payment mode, not the current global setting + const payoutEligibleStartMode = (recordedPaymentMode === 'PAID_SINGLE') || (recordedPaymentMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); if (payoutEligibleStartMode && won) { - const { amount: payoutAmount, multiplier } = this.calculatePayout(this.gameMode, { treasureFound }); + const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedPaymentMode, { treasureFound }); + + // Check if payout was already processed for this game (prevent duplicates) + if (gameDbId) { + const existingPayout = await this.db.query(` + SELECT id FROM payouts WHERE game_id = $1 AND status IN ('pending', 'completed') + `, [gameDbId]); + if (existingPayout.rows.length > 0) { + console.log(`⚠️ Payout already exists for game ${gameId}, skipping duplicate`); + return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Payout already processed', score: metrics.score ?? null }; + } + } // Look up user record for payout address const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]); @@ -1178,7 +1222,7 @@ class GameModeManager { }); return { success: true, - mode: this.gameMode, + mode: recordedPaymentMode, payout: { amount: payoutAmount, multiplier, @@ -1189,14 +1233,14 @@ class GameModeManager { } catch (payoutErr) { const normalizedPayout = normalizeError(payoutErr, 'Failed to send payout'); console.error('❌ Error creating payout:', normalizedPayout.message); - return { success: true, mode: this.gameMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null }; + return { success: true, mode: recordedPaymentMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null }; } } - return { success: true, mode: this.gameMode, payout: null, reason: 'No payout address', score: metrics.score ?? null }; + return { success: true, mode: recordedPaymentMode, payout: null, reason: 'No payout address', score: metrics.score ?? null }; } // Credits mode: decrement nothing here (already handled start). Optionally could award stats. - return { success: true, mode: this.gameMode, payout: null, score: metrics.score ?? null }; + return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null }; } catch (err) { const normalized = normalizeError(err, 'Failed to complete game'); console.error('❌ completeGame error:', normalized.message); diff --git a/src/migrations/001_initial_schema.sql b/src/migrations/001_initial_schema.sql index b705512..4ca8ade 100644 --- a/src/migrations/001_initial_schema.sql +++ b/src/migrations/001_initial_schema.sql @@ -61,7 +61,7 @@ CREATE TABLE payments ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id), socket_id VARCHAR(255), - subaddress VARCHAR(95) NOT NULL, -- MoneroPay subaddress + subaddress VARCHAR(95) NOT NULL, -- Wallet RPC generated subaddress expected_amount BIGINT NOT NULL, -- In atomic units received_amount BIGINT DEFAULT 0, -- In atomic units payment_type VARCHAR(20) NOT NULL, -- single_game, credits_package diff --git a/src/payments/moneroPayService.js b/src/payments/moneroPayService.js deleted file mode 100644 index 96c7f1f..0000000 --- a/src/payments/moneroPayService.js +++ /dev/null @@ -1,368 +0,0 @@ -/** - * MoneroPay Integration Service - * Handles payment requests, monitoring, and payouts - */ - -const axios = require('axios'); -const { v4: uuidv4 } = require('uuid'); - -class MoneroPayService { - constructor(databaseManager) { - this.db = databaseManager; - this.endpoint = process.env.MONEROPAY_ENDPOINT; - this.callbackBaseUrl = process.env.MONEROPAY_CALLBACK_BASE_URL; - this.initialized = false; - - if (!this.endpoint) { - throw new Error('MONEROPAY_ENDPOINT not configured'); - } - - console.log(`💰 MoneroPay service initialized: ${this.endpoint}`); - } - - /** - * Initialize MoneroPay service - */ - async initialize() { - try { - // Test connection to MoneroPay endpoint - const response = await axios.get(`${this.endpoint}/health`, { timeout: 5000 }); - - if (response.status === 200) { - this.initialized = true; - console.log('✅ MoneroPay service connection verified'); - return true; - } else { - console.log('❌ MoneroPay service health check failed'); - return false; - } - } catch (error) { - console.log(`❌ MoneroPay service connection failed: ${error.message}`); - return false; - } - } - - /** - * Create a payment request for a user - */ - async createPaymentRequest(userId, socketId, paymentType, amount, description) { - try { - const callbackUrl = `${this.callbackBaseUrl}/${socketId}`; - - const response = await axios.post(`${this.endpoint}/receive`, { - amount: parseInt(amount), - description: description || `Wownerogue ${paymentType}`, - callback_url: callbackUrl - }); - - const { address, amount: expectedAmount, created_at } = response.data; - - // Store payment request in database - const paymentRecord = await this.db.query(` - INSERT INTO payments ( - user_id, socket_id, subaddress, expected_amount, - payment_type, description, status - ) VALUES ($1, $2, $3, $4, $5, $6, 'pending') - RETURNING * - `, [userId, socketId, address, expectedAmount, paymentType, description]); - - console.log(`💳 Payment request created: ${address} for ${expectedAmount} atomic units`); - - return { - id: paymentRecord.rows[0].id, - address, - amount: expectedAmount, - paymentType, - description, - expiresAt: paymentRecord.rows[0].expires_at - }; - - } catch (error) { - console.error('❌ Failed to create payment request:', error.message); - throw new Error('Failed to create payment request'); - } - } - - /** - * Check payment status for a subaddress - */ - async checkPaymentStatus(subaddress) { - try { - const response = await axios.get(`${this.endpoint}/receive/${subaddress}`); - const data = response.data; - - return { - complete: data.complete, - expectedAmount: data.amount.expected, - coveredAmount: data.amount.covered.total, - unlockedAmount: data.amount.covered.unlocked, - transactions: data.transactions || [] - }; - - } catch (error) { - console.error(`❌ Failed to check payment status for ${subaddress}:`, error.message); - return null; - } - } - - /** - * Process payment callback (when payment confirmed) - */ - async processPaymentCallback(paymentData) { - try { - // Find the payment record - const paymentResult = await this.db.query(` - SELECT * FROM payments - WHERE subaddress = $1 AND status = 'pending' - `, [paymentData.address]); - - if (paymentResult.rows.length === 0) { - console.warn('⚠️ Payment callback for unknown address:', paymentData.address); - return false; - } - - const paymentRecord = paymentResult.rows[0]; - - // Update payment status - await this.db.query(` - UPDATE payments - SET status = 'confirmed', - received_amount = $1, - confirmed_at = NOW(), - tx_hash = $2, - block_height = $3, - confirmations = $4 - WHERE id = $5 - `, [ - paymentData.amount, - paymentData.tx_hash, - paymentData.height, - paymentData.confirmations, - paymentRecord.id - ]); - - // Handle different payment types - if (paymentRecord.payment_type === 'credits_package') { - // Parse credits from description or use default - // Description format: "Wowngeon X credits package (WOW)" - let creditsToAdd = 10; // Default fallback - const descMatch = paymentRecord.description?.match(/(\d+)\s*credits/i); - if (descMatch) { - creditsToAdd = parseInt(descMatch[1], 10) || 10; - } - - await this.db.query(` - UPDATE users - SET credits = credits + $1, - total_amount_paid = total_amount_paid + $2, - updated_at = NOW() - WHERE id = $3 - `, [creditsToAdd, paymentData.amount, paymentRecord.user_id]); - - console.log(`💰 Added ${creditsToAdd} credits to user ${paymentRecord.user_id}`); - } - - console.log(`✅ Payment confirmed: ${paymentData.tx_hash} for ${paymentData.amount} atomic units`); - return true; - - } catch (error) { - console.error('❌ Failed to process payment callback:', error.message); - return false; - } - } - - /** - * Create a payout for game winner - */ - async createPayout(userId, gameId, payoutAddress, amount) { - try { - const result = await this.db.query(` - INSERT INTO payouts ( - user_id, game_id, payout_address, amount, status - ) VALUES ($1, $2, $3, $4, 'pending') - RETURNING * - `, [userId, gameId, payoutAddress, amount]); - - console.log(`💸 Payout created: ${amount} atomic units to ${payoutAddress}`); - return result.rows[0]; - - } catch (error) { - console.error('❌ Failed to create payout:', error.message); - throw error; - } - } - - /** - * Process pending payouts in batches - */ - async processPendingPayouts() { - const maxRetries = parseInt(process.env.PAYOUT_MAX_RETRIES) || 3; - - try { - const maxBatchSize = parseInt(process.env.MAX_PAYOUT_BATCH_SIZE) || 50; - - // Get pending payouts that haven't exceeded retry limit - const payouts = await this.db.query(` - SELECT * FROM payouts - WHERE status = 'pending' AND (retry_count IS NULL OR retry_count < $1) - ORDER BY created_at ASC - LIMIT $2 - `, [maxRetries, maxBatchSize]); - - if (payouts.rows.length === 0) { - return { processed: 0, message: 'No pending payouts' }; - } - - // Group payouts by address to optimize transaction fees - const payoutGroups = this.groupPayoutsByAddress(payouts.rows); - const batchId = uuidv4(); - - // Prepare destinations for MoneroPay - const destinations = payoutGroups.map(group => ({ - address: group.address, - amount: group.totalAmount - })); - - // Send batch transfer - const transferResponse = await axios.post(`${this.endpoint}/transfer`, { - destinations - }); - - const { tx_hash_list, fee, amount: totalAmount } = transferResponse.data; - - // Update payout records - const payoutIds = payouts.rows.map(p => p.id); - await this.db.query(` - UPDATE payouts - SET status = 'completed', - batch_id = $1, - tx_hash = $2, - fee = $3, - processed_at = NOW() - WHERE id = ANY($4) - `, [batchId, tx_hash_list[0], fee, payoutIds]); - - // Update user totals - for (const payout of payouts.rows) { - await this.db.query(` - UPDATE users - SET total_amount_won = total_amount_won + $1, - updated_at = NOW() - WHERE id = $2 - `, [payout.amount, payout.user_id]); - } - - console.log(`✅ Processed ${payouts.rows.length} payouts in batch ${batchId}`); - console.log(`💸 Total amount: ${totalAmount}, Fee: ${fee}, TX: ${tx_hash_list.join(', ')}`); - - return { - processed: payouts.rows.length, - batchId, - totalAmount, - fee, - transactions: tx_hash_list - }; - - } catch (error) { - console.error('❌ Failed to process payouts:', error.message); - - // Increment retry count for pending payouts, mark as failed if max retries exceeded - const pendingPayouts = await this.db.query(` - SELECT id, retry_count FROM payouts - WHERE status = 'pending' - `); - - for (const payout of pendingPayouts.rows) { - const newRetryCount = (payout.retry_count || 0) + 1; - const errorMessage = error.message || 'Unknown error'; - - if (newRetryCount >= maxRetries) { - // Max retries exceeded - mark as permanently failed - await this.db.query(` - UPDATE payouts - SET status = 'failed', - retry_count = $1, - last_error = $2, - last_retry_at = NOW() - WHERE id = $3 - `, [newRetryCount, errorMessage, payout.id]); - - console.error(`💀 Payout ${payout.id} permanently failed after ${newRetryCount} attempts: ${errorMessage}`); - } else { - // Increment retry count, will try again next batch - await this.db.query(` - UPDATE payouts - SET retry_count = $1, - last_error = $2, - last_retry_at = NOW() - WHERE id = $3 - `, [newRetryCount, errorMessage, payout.id]); - - console.warn(`⚠️ Payout ${payout.id} failed (attempt ${newRetryCount}/${maxRetries}): ${errorMessage}`); - } - } - - throw error; - } - } - - /** - * Group payouts by address to minimize transaction fees - */ - groupPayoutsByAddress(payouts) { - const groups = {}; - - for (const payout of payouts) { - if (!groups[payout.payout_address]) { - groups[payout.payout_address] = { - address: payout.payout_address, - totalAmount: 0, - payouts: [] - }; - } - - groups[payout.payout_address].totalAmount += parseInt(payout.amount); - groups[payout.payout_address].payouts.push(payout); - } - - return Object.values(groups); - } - - /** - * Get wallet balance - */ - async getBalance() { - try { - const response = await axios.get(`${this.endpoint}/balance`); - return response.data; - } catch (error) { - console.error('❌ Failed to get wallet balance:', error.message); - return { total: 0, unlocked: 0 }; - } - } - - /** - * Cleanup expired payment requests - */ - async cleanupExpiredPayments() { - try { - const result = await this.db.query(` - UPDATE payments - SET status = 'expired' - WHERE status = 'pending' AND expires_at < NOW() - RETURNING id - `); - - if (result.rows.length > 0) { - console.log(`🧹 Cleaned up ${result.rows.length} expired payments`); - } - - return result.rows.length; - } catch (error) { - console.error('❌ Failed to cleanup expired payments:', error.message); - return 0; - } - } -} - -module.exports = MoneroPayService; diff --git a/test/payment_payout_comprehensive.test.js b/test/payment_payout_comprehensive.test.js new file mode 100644 index 0000000..af122a1 --- /dev/null +++ b/test/payment_payout_comprehensive.test.js @@ -0,0 +1,641 @@ +/** + * Comprehensive Payment and Payout Tests + * Deep testing of all payment flows, credits, payouts, and edge cases + */ + +const GameModeManager = require('../src/game/gameModeManager'); + +// Mock dependencies +const createMockDb = () => ({ + query: jest.fn() +}); + +const createMockWalletService = () => ({ + createPaymentRequest: jest.fn(), + processPayout: jest.fn(), + startPaymentMonitoring: jest.fn(), + stopPaymentMonitoring: jest.fn() +}); + +const createMockDebugManager = () => ({ + CONSOLE_LOGGING: false, + getCurrentBlockHeight: () => 12345 +}); + +const createMockPaymentConfig = (overrides = {}) => ({ + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'WOW', decimals: 11 }, + modes: { + direct: { enabled: true, price: 100000000000n }, + credits: { enabled: true, creditsPerGame: 1, packages: [ + { id: 'small', credits: 10, price: '500000000000', bonus: 0 }, + { id: 'medium', credits: 25, price: '1000000000000', bonus: 2 } + ] } + }, + payouts: { + rules: { + direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } }, + credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n } + } + }, + preferences: { preferCreditsFirst: true }, + earlyEntry: { enabled: true, allowInFreeMode: true, allowInCreditsMode: true }, + ...overrides + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on: () => {} } +}); + +describe('Payment & Payout Comprehensive Tests', () => { + let gmm; + let mockDb; + let mockWalletService; + let mockDebugManager; + let mockPaymentConfig; + + beforeEach(() => { + jest.clearAllMocks(); + mockDb = createMockDb(); + mockWalletService = createMockWalletService(); + mockDebugManager = createMockDebugManager(); + mockPaymentConfig = createMockPaymentConfig(); + + gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfig); + }); + + describe('Credit Deduction - Negative Balance Prevention', () => { + test('should fail gracefully when user has insufficient credits', async () => { + const user = { id: 1, credits: 0 }; + gmm.creditsPerGameCost = 1; + + // The credits update should return 0 rows because WHERE credits >= 1 fails + // (when user.credits = 0 and we require >= 1) + mockDb.query.mockResolvedValueOnce({ rows: [] }); // UPDATE users ... WHERE credits >= 1 - returns empty! + + const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123'); + + expect(result.success).toBe(false); + expect(result.reason).toBe('Insufficient credits'); + expect(result.creditsRequired).toBe(1); + }); + + test('should succeed when user has exactly enough credits', async () => { + const user = { id: 1, credits: 1 }; + gmm.creditsPerGameCost = 1; + + // UPDATE returns the new balance of 0 + mockDb.query.mockResolvedValueOnce({ rows: [{ credits: 0 }] }); // UPDATE succeeds + mockDb.query.mockResolvedValueOnce({ rows: [] }); // UPDATE games + mockDb.query.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + + const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123'); + + expect(result.success).toBe(true); + expect(result.creditsRemaining).toBe(0); + expect(result.creditsSpent).toBe(1); + }); + + test('should deduct correct amount for multi-credit games', async () => { + const user = { id: 1, credits: 5 }; + gmm.creditsPerGameCost = 2; + + mockDb.query.mockResolvedValueOnce({ rows: [{ credits: 3 }] }); // UPDATE succeeds + mockDb.query.mockResolvedValueOnce({ rows: [] }); // UPDATE games + mockDb.query.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + + const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123'); + + expect(result.success).toBe(true); + expect(result.creditsRemaining).toBe(3); + expect(result.creditsSpent).toBe(2); + }); + }); + + describe('Payout Calculation', () => { + test('direct mode escape payout calculated correctly', () => { + gmm.singleGamePrice = 100000000000; + gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + + const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false }); + + expect(result.amount).toBe(200000000000); + expect(result.multiplier).toBe(2); + expect(result.base).toBe(100000000000); + }); + + test('direct mode treasure payout calculated correctly', () => { + gmm.singleGamePrice = 100000000000; + gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + + const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: true }); + + expect(result.amount).toBe(300000000000); + expect(result.multiplier).toBe(3); + }); + + test('credits mode uses base value not single game price', () => { + gmm.singleGamePrice = 100000000000; + gmm.creditsPayoutBaseValue = 50000000000; + gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + + const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false }); + + expect(result.amount).toBe(100000000000); // 50000000000 * 2 + expect(result.base).toBe(50000000000); + }); + + test('zero multiplier returns zero payout', () => { + gmm.creditPayoutMultipliers = { escape: 0, escapeWithTreasure: 0 }; + gmm.creditsPayoutBaseValue = 100000000000; + + const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false }); + + expect(result.amount).toBe(0); + }); + + test('fractional multipliers work correctly', () => { + gmm.singleGamePrice = 100000000000; + gmm.directPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2.5 }; + + const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false }); + + expect(result.amount).toBe(150000000000); + }); + }); + + describe('Mixed Mode Payment Selection', () => { + beforeEach(() => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.creditsPerGameCost = 1; + }); + + test('prefers credits when user has credits and preferCreditsFirst is true', async () => { + gmm.preferCreditsFirst = true; + const user = { id: 1, credits: 5, payout_address: 'wow1addr' }; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + + const result = await gmm.canUserStartGame('socket1'); + + expect(result.allowed).toBe(true); + expect(result.useCredits).toBe(true); + expect(result.effectiveMode).toBe('PAID_CREDITS'); + }); + + test('uses direct payment when user has no credits', async () => { + gmm.preferCreditsFirst = true; + const user = { id: 1, credits: 0, payout_address: 'wow1addr' }; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + // Has confirmed payment + mockDb.query.mockResolvedValueOnce({ rows: [{ id: 123 }] }); // confirmed payment query + + const result = await gmm.canUserStartGame('socket1'); + + expect(result.allowed).toBe(true); + expect(result.paymentId).toBe(123); + expect(result.effectiveMode).toBe('PAID_SINGLE'); + }); + + test('denies when no credits and no confirmed payment', async () => { + const user = { id: 1, credits: 0, payout_address: 'wow1addr' }; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no confirmed payment + // getPaymentOptionsForUser will be called + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser again + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + + const result = await gmm.canUserStartGame('socket1'); + + expect(result.allowed).toBe(false); + expect(result.action).toBe('choose_payment'); + }); + }); + + describe('Credits Package Confirmation', () => { + test('adds credits from package info correctly', async () => { + const user = { id: 1, credits: 0 }; + + mockDb.query + .mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser + .mockResolvedValueOnce({ rows: [] }) // update last_active + .mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users + .mockResolvedValueOnce({ rows: [] }) // UPDATE payments + .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + + const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 }); + + expect(result.success).toBe(true); + expect(result.creditsAdded).toBe(15); + expect(result.newBalance).toBe(15); + }); + + test('parses credits from payment description as fallback', async () => { + const user = { id: 1, credits: 5 }; + + mockDb.query + .mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser + .mockResolvedValueOnce({ rows: [] }) // update last_active + .mockResolvedValueOnce({ rows: [{ description: 'Wowngeon 25 credits package (WOW)' }] }) // SELECT payments + .mockResolvedValueOnce({ rows: [{ credits: 30 }] }) // UPDATE users + .mockResolvedValueOnce({ rows: [] }) // UPDATE payments + .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + + const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null); + + expect(result.success).toBe(true); + expect(result.creditsAdded).toBe(25); // Parsed from description + }); + + test('defaults to 10 credits when no info available', async () => { + const user = { id: 1, credits: 0 }; + + mockDb.query + .mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser + .mockResolvedValueOnce({ rows: [] }) // update last_active + .mockResolvedValueOnce({ rows: [{ description: 'Some payment' }] }) // SELECT payments - no credits pattern + .mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users + .mockResolvedValueOnce({ rows: [] }) // UPDATE payments + .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + + const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null); + + expect(result.success).toBe(true); + expect(result.creditsAdded).toBe(10); // Default fallback + }); + }); + + describe('Game Completion & Payout Flow', () => { + beforeEach(() => { + gmm.gameMode = 'PAID_SINGLE'; + gmm.singleGamePrice = 100000000000; + gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + }); + + test('processes winning game with payout correctly', async () => { + mockWalletService.processPayout.mockResolvedValueOnce({ + success: true, + txHash: 'abc123', + fee: 1000000 + }); + + // Game record lookup + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'direct' }] + }); + // Game update + mockDb.query.mockResolvedValueOnce({ rows: [] }); + // Check for existing payout + mockDb.query.mockResolvedValueOnce({ rows: [] }); + // User lookup + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: 'wow1testaddress' }] + }); + + const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 50 }); + + expect(result.success).toBe(true); + expect(result.payout).toBeDefined(); + expect(result.payout.amount).toBe(200000000000); + expect(result.payout.multiplier).toBe(2); + expect(mockWalletService.processPayout).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 200000000000, + address: 'wow1testaddress' + }) + ); + }); + + test('treasure bonus applied correctly to payout', async () => { + mockWalletService.processPayout.mockResolvedValueOnce({ + success: true, + txHash: 'abc123', + fee: 1000000 + }); + + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'direct' }] + }); + mockDb.query.mockResolvedValueOnce({ rows: [] }); + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: 'wow1testaddress' }] + }); + + const result = await gmm.completeGame('socket1', 'game123', true, true, { moves: 30 }); + + expect(result.payout.amount).toBe(300000000000); // 3x for treasure + expect(result.payout.multiplier).toBe(3); + expect(result.payout.treasure).toBe(true); + }); + + test('no payout for losing game', async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'direct' }] + }); + mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update + + const result = await gmm.completeGame('socket1', 'game123', false, false, {}); + + expect(result.success).toBe(true); + expect(result.payout).toBeNull(); + expect(mockWalletService.processPayout).not.toHaveBeenCalled(); + }); + + test('no payout when user has no payout address', async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'direct' }] + }); + mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: null }] // No address + }); + + const result = await gmm.completeGame('socket1', 'game123', true, false, {}); + + expect(result.success).toBe(true); + expect(result.payout).toBeNull(); + expect(result.reason).toBe('No payout address'); + expect(mockWalletService.processPayout).not.toHaveBeenCalled(); + }); + + test('prevents duplicate payout for same game', async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'direct' }] + }); + mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update + // Existing payout found + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 999 }] + }); + + const result = await gmm.completeGame('socket1', 'game123', true, false, {}); + + expect(result.success).toBe(true); + expect(result.payout).toBeNull(); + expect(result.reason).toBe('Payout already processed'); + expect(mockWalletService.processPayout).not.toHaveBeenCalled(); + }); + + test('uses recorded payment_mode not current gameMode', async () => { + // Current global mode is PAID_SINGLE + gmm.gameMode = 'PAID_SINGLE'; + // But the game was started in credits mode + gmm.creditsPayoutEnabled = true; + gmm.creditsPayoutBaseValue = 50000000000; + gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 }; + + mockWalletService.processPayout.mockResolvedValueOnce({ + success: true, + txHash: 'xyz789' + }); + + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'credits' }] // Game was started with credits + }); + mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: 'wow1testaddress' }] + }); + + const result = await gmm.completeGame('socket1', 'game123', true, false, {}); + + // Should use credits multipliers, not direct + expect(result.payout.amount).toBe(75000000000); // 50000000000 * 1.5 + expect(result.payout.multiplier).toBe(1.5); + }); + }); + + describe('Payment Request Creation', () => { + test('creates single_game payment request correctly', async () => { + const user = { id: 1, credits: 0 }; + gmm.singleGamePrice = 100000000000; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment + + mockWalletService.createPaymentRequest.mockResolvedValueOnce({ + address: 'wow1paymentaddr', + expiresAt: new Date() + }); + + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 999, expires_at: new Date() }] + }); // INSERT payment + + const result = await gmm.createPaymentRequest('socket1', 'single_game'); + + expect(result.address).toBe('wow1paymentaddr'); + expect(result.amount).toBe(100000000000); + expect(result.paymentType).toBe('single_game'); + expect(mockWalletService.createPaymentRequest).toHaveBeenCalledWith( + 100000000000, + expect.stringContaining('single game'), + 1, + 'socket1' + ); + }); + + test('creates credits_package payment request correctly', async () => { + const user = { id: 1, credits: 0 }; + gmm.creditsPackagePrice = 500000000000; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment + + mockWalletService.createPaymentRequest.mockResolvedValueOnce({ + address: 'wow1creditspayment', + expiresAt: new Date() + }); + + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1000, expires_at: new Date() }] + }); // INSERT payment + + const result = await gmm.createPaymentRequest('socket1', 'credits_package'); + + expect(result.address).toBe('wow1creditspayment'); + expect(result.paymentType).toBe('credits_package'); + expect(result.package).toBeDefined(); + }); + + test('reuses existing pending payment request', async () => { + const user = { id: 1, credits: 0 }; + gmm.singleGamePrice = 100000000000; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + // Existing pending payment + mockDb.query.mockResolvedValueOnce({ + rows: [{ + id: 555, + subaddress: 'wow1existingaddr', + expected_amount: '100000000000', + payment_type: 'single_game', + status: 'pending', + expires_at: new Date(Date.now() + 1800000) + }] + }); + + const result = await gmm.createPaymentRequest('socket1', 'single_game', { reuseExisting: true }); + + expect(result.id).toBe(555); + expect(result.address).toBe('wow1existingaddr'); + expect(result.reused).toBe(true); + // Should not create a new payment + expect(mockWalletService.createPaymentRequest).not.toHaveBeenCalled(); + }); + + test('throws on invalid payment type', async () => { + const user = { id: 1, credits: 0 }; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + + await expect(gmm.createPaymentRequest('socket1', 'invalid_type')) + .rejects.toThrow(/Invalid payment type/); + }); + }); + + describe('Game Mode Info', () => { + test('returns complete game mode info', () => { + gmm.gameMode = 'PAID_SINGLE'; + gmm.cryptoType = 'WOW'; + gmm.singleGamePrice = 100000000000; + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = false; + + const info = gmm.getGameModeInfo(); + + expect(info.mode).toBe('PAID_SINGLE'); + expect(info.cryptoType).toBe('WOW'); + expect(info.singleGamePrice).toBe(100000000000); + expect(info.paymentsEnabled).toBe(true); + expect(info.directModeEnabled).toBe(true); + expect(info.creditsModeEnabled).toBe(false); + expect(info.earlyEntry).toBeDefined(); + expect(info.payoutMultipliers).toBeDefined(); + }); + + test('includes testnet warning for XMR stagenet', () => { + gmm.cryptoType = 'XMR'; + gmm.network = 'stagenet'; + gmm.isTestNetwork = true; + + const info = gmm.getGameModeInfo(); + + expect(info.isTestNetwork).toBe(true); + expect(info.testnetWarning).toContain('STAGENET'); + }); + + test('no testnet warning for WOW (only has mainnet)', () => { + gmm.cryptoType = 'WOW'; + gmm.network = 'mainnet'; + gmm.isTestNetwork = false; + + const info = gmm.getGameModeInfo(); + + expect(info.isTestNetwork).toBe(false); + expect(info.testnetWarning).toBeNull(); + }); + }); + + describe('User Stats', () => { + test('returns user statistics from database function', async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ + total_games: 100, + games_won: 45, + win_rate: 0.45, + total_paid: 5000000000000, + total_won: 6000000000000, + net_profit: 1000000000000, + credits_remaining: 15 + }] + }); + + const stats = await gmm.getUserStats('socket1'); + + expect(stats.total_games).toBe(100); + expect(stats.games_won).toBe(45); + expect(stats.net_profit).toBe(1000000000000); + }); + + test('returns default stats when none found', async () => { + mockDb.query.mockResolvedValueOnce({ rows: [] }); + + const stats = await gmm.getUserStats('socket1'); + + expect(stats.total_games).toBe(0); + expect(stats.games_won).toBe(0); + expect(stats.net_profit).toBe(0); + }); + }); +}); + +describe('Edge Cases and Error Handling', () => { + let gmm; + let mockDb; + let mockWalletService; + + beforeEach(() => { + mockDb = createMockDb(); + mockWalletService = createMockWalletService(); + gmm = new GameModeManager(mockDb, mockWalletService, createMockDebugManager(), createMockPaymentConfig()); + }); + + test('handles wallet service error during payout', async () => { + gmm.gameMode = 'PAID_SINGLE'; + + mockWalletService.processPayout.mockRejectedValueOnce(new Error('Wallet unavailable')); + + mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, payment_mode: 'direct' }] }); + mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: 'wow1testaddress' }] + }); + + const result = await gmm.completeGame('socket1', 'game123', true, false, {}); + + expect(result.success).toBe(true); + expect(result.payout).toBeNull(); + expect(result.payoutError).toBeDefined(); + }); + + test('handles database error during credits deduction', async () => { + const user = { id: 1, credits: 5 }; + + mockDb.query.mockRejectedValueOnce(new Error('Database connection lost')); + + await expect(gmm._processGameStartWithCredits(user, 'socket1', 'game123')) + .rejects.toThrow('Database connection lost'); + }); + + test('handles payment creation when wallet is unavailable', async () => { + const user = { id: 1 }; + + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser + mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active + mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment + + mockWalletService.createPaymentRequest.mockRejectedValueOnce( + new Error('RPC connection refused') + ); + + await expect(gmm.createPaymentRequest('socket1', 'single_game')) + .rejects.toThrow(); + }); +}); diff --git a/test/walletRPCService.test.js b/test/walletRPCService.test.js index 62dc9f3..418ff2e 100644 --- a/test/walletRPCService.test.js +++ b/test/walletRPCService.test.js @@ -14,6 +14,10 @@ describe('WalletRPCService interface', () => { expect(typeof svc.startPaymentMonitoring).toBe('function'); expect(typeof svc.stopPaymentMonitoring).toBe('function'); expect(typeof svc.processBatchPayouts).toBe('function'); // newly added stub + expect(typeof svc.processPayout).toBe('function'); + expect(typeof svc.normalizeAtomicAmount).toBe('function'); + expect(typeof svc.cleanupUserPayments).toBe('function'); + expect(typeof svc.getHealthStatus).toBe('function'); }); test('processBatchPayouts stub returns processed:0', async () => { @@ -44,3 +48,410 @@ describe('WalletRPCService interface', () => { }); }); }); + +describe('WalletRPCService.normalizeAtomicAmount', () => { + let svc; + + beforeEach(() => { + svc = new WalletRPCService(debugManagerMock); + }); + + test('accepts valid positive number', () => { + expect(svc.normalizeAtomicAmount(100)).toBe(100); + expect(svc.normalizeAtomicAmount(1)).toBe(1); + expect(svc.normalizeAtomicAmount(100000000000)).toBe(100000000000); + }); + + test('accepts valid positive bigint', () => { + expect(svc.normalizeAtomicAmount(100n)).toBe(100); + expect(svc.normalizeAtomicAmount(1n)).toBe(1); + }); + + test('converts large bigint to string when beyond safe integer', () => { + const largeValue = BigInt(Number.MAX_SAFE_INTEGER) + 1000n; + const result = svc.normalizeAtomicAmount(largeValue); + expect(typeof result).toBe('string'); + expect(result).toBe(largeValue.toString()); + }); + + test('truncates floating point numbers to integers', () => { + expect(svc.normalizeAtomicAmount(100.9)).toBe(100); + expect(svc.normalizeAtomicAmount(100.1)).toBe(100); + expect(svc.normalizeAtomicAmount(99.9999)).toBe(99); + }); + + test('throws on zero amount', () => { + expect(() => svc.normalizeAtomicAmount(0)).toThrow(); + }); + + test('throws on negative amount', () => { + expect(() => svc.normalizeAtomicAmount(-100)).toThrow(); + expect(() => svc.normalizeAtomicAmount(-1)).toThrow(); + }); + + test('throws on zero bigint', () => { + expect(() => svc.normalizeAtomicAmount(0n)).toThrow(); + }); + + test('throws on negative bigint', () => { + expect(() => svc.normalizeAtomicAmount(-100n)).toThrow(); + }); + + test('throws on null/undefined', () => { + expect(() => svc.normalizeAtomicAmount(null)).toThrow(); + expect(() => svc.normalizeAtomicAmount(undefined)).toThrow(); + }); + + test('throws on NaN', () => { + expect(() => svc.normalizeAtomicAmount(NaN)).toThrow(); + }); + + test('throws on Infinity', () => { + expect(() => svc.normalizeAtomicAmount(Infinity)).toThrow(); + expect(() => svc.normalizeAtomicAmount(-Infinity)).toThrow(); + }); + + test('throws when truncated value is zero', () => { + expect(() => svc.normalizeAtomicAmount(0.5)).toThrow(); + expect(() => svc.normalizeAtomicAmount(0.001)).toThrow(); + }); +}); + +describe('WalletRPCService.processPayout', () => { + let svc; + + beforeEach(() => { + svc = new WalletRPCService(debugManagerMock); + }); + + test('validates address is provided', async () => { + await expect(svc.processPayout({ amount: 100 })) + .rejects.toThrow(/address/i); + + await expect(svc.processPayout({ address: '', amount: 100 })) + .rejects.toThrow(/address/i); + + await expect(svc.processPayout({ address: 123, amount: 100 })) + .rejects.toThrow(/address/i); + }); + + test('validates amount is positive', async () => { + await expect(svc.processPayout({ + address: 'wow1addr', + amount: 0 + })).rejects.toThrow(/amount/i); + + await expect(svc.processPayout({ + address: 'wow1addr', + amount: -100 + })).rejects.toThrow(/amount/i); + }); + + test('calls transfer RPC with correct parameters', async () => { + svc.rpcCall = jest.fn().mockResolvedValueOnce({ + result: { + tx_hash: 'abc123def456', + tx_key: 'key789', + fee: 1000000 + } + }); + + const result = await svc.processPayout({ + address: 'wow1testaddress123456789', + amount: 100000000000, + userId: 'user1', + gameId: 'game123' + }); + + expect(result.success).toBe(true); + expect(result.txHash).toBe('abc123def456'); + expect(result.txKey).toBe('key789'); + expect(result.fee).toBe(1000000); + expect(result.userId).toBe('user1'); + expect(result.gameId).toBe('game123'); + expect(result.amount).toBe(100000000000); + + expect(svc.rpcCall).toHaveBeenCalledWith('transfer', { + destinations: [{ + amount: 100000000000, + address: 'wow1testaddress123456789' + }], + account_index: 0, + priority: 1, + get_tx_key: true + }); + }); + + test('includes subaddr_indices when subaddressIndex specified', async () => { + svc.rpcCall = jest.fn().mockResolvedValueOnce({ + result: { tx_hash: 'xyz', tx_key: 'key', fee: 100 } + }); + + await svc.processPayout({ + address: 'wow1addr', + amount: 100, + subaddressIndex: 5 + }); + + expect(svc.rpcCall).toHaveBeenCalledWith('transfer', + expect.objectContaining({ + subaddr_indices: [5] + }) + ); + }); + + test('does not include subaddr_indices for invalid index values', async () => { + svc.rpcCall = jest.fn().mockResolvedValueOnce({ + result: { tx_hash: 'xyz', tx_key: 'key', fee: 100 } + }); + + await svc.processPayout({ + address: 'wow1addr', + amount: 100, + subaddressIndex: -1 + }); + + expect(svc.rpcCall).toHaveBeenCalledWith('transfer', + expect.not.objectContaining({ + subaddr_indices: expect.anything() + }) + ); + }); + + test('handles RPC failure and wraps error', async () => { + svc.rpcCall = jest.fn().mockRejectedValueOnce(new Error('Insufficient balance')); + + await expect(svc.processPayout({ + address: 'wow1addr', + amount: 100 + })).rejects.toMatchObject({ + code: 'EXTERNAL_SERVICE_ERROR', + safeMessage: 'Unable to send payout at this time.' + }); + }); +}); + +describe('WalletRPCService.createPaymentRequest', () => { + let svc; + + beforeEach(() => { + svc = new WalletRPCService(debugManagerMock); + }); + + test('creates payment request with correct parameters', async () => { + svc.rpcCall = jest.fn().mockResolvedValueOnce({ + result: { + address: 'wow1subaddress123', + address_index: 42 + } + }); + + const result = await svc.createPaymentRequest( + 100000000000, + 'Game entry payment', + 'user123', + 'socket456' + ); + + expect(result.success).toBe(true); + expect(result.address).toBe('wow1subaddress123'); + expect(result.addressIndex).toBe(42); + expect(result.amount).toBe(100000000000); + expect(result.description).toBe('Game entry payment'); + expect(result.id).toBeDefined(); + expect(result.expiresAt).toBeInstanceOf(Date); + + expect(svc.rpcCall).toHaveBeenCalledWith('create_address', { + account_index: 0, + label: expect.stringContaining('game_user123_') + }); + }); + + test('stores payment info for monitoring', async () => { + svc.rpcCall = jest.fn().mockResolvedValueOnce({ + result: { + address: 'wow1subaddress123', + address_index: 42 + } + }); + + await svc.createPaymentRequest( + 100000000000, + 'Test payment', + 'user123', + 'socket456' + ); + + expect(svc.addressToUser.has('wow1subaddress123')).toBe(true); + expect(svc.addressToSocket.has('wow1subaddress123')).toBe(true); + + const userInfo = svc.addressToUser.get('wow1subaddress123'); + expect(userInfo.userId).toBe('user123'); + expect(userInfo.socketId).toBe('socket456'); + expect(userInfo.amount).toBe(100000000000); + expect(userInfo.addressIndex).toBe(42); + expect(userInfo.status).toBe('pending'); + expect(userInfo.detected).toBe(false); + expect(userInfo.confirmed).toBe(false); + }); +}); + +describe('WalletRPCService Payment Monitoring', () => { + let svc; + + beforeEach(() => { + svc = new WalletRPCService(debugManagerMock); + jest.useFakeTimers(); + }); + + afterEach(() => { + // Cleanup any watchers + for (const [addr] of svc.paymentWatchers) { + svc.stopPaymentMonitoring(addr); + } + jest.useRealTimers(); + }); + + test('starts and stops payment monitoring', () => { + const callback = jest.fn(); + const address = 'wow1testaddr'; + + svc.checkPaymentStatus = jest.fn().mockResolvedValue({ + complete: false, + in_mempool: false, + confirmed: false + }); + + svc.startPaymentMonitoring(address, callback, 100); + + expect(svc.paymentWatchers.has(address)).toBe(true); + + svc.stopPaymentMonitoring(address); + + expect(svc.paymentWatchers.has(address)).toBe(false); + }); + + test('does not start duplicate monitors for same address', () => { + const callback = jest.fn(); + const address = 'wow1testaddr'; + + svc.checkPaymentStatus = jest.fn().mockResolvedValue({ + complete: false + }); + + svc.startPaymentMonitoring(address, callback, 100); + const firstWatcher = svc.paymentWatchers.get(address); + + svc.startPaymentMonitoring(address, callback, 100); // Duplicate + const secondWatcher = svc.paymentWatchers.get(address); + + // Should be the same watcher + expect(firstWatcher).toBe(secondWatcher); + expect(svc.paymentWatchers.size).toBe(1); + + svc.stopPaymentMonitoring(address); + }); + + test('cleanupUserPayments removes all monitors for user', () => { + const userId = 'user123'; + const addr1 = 'wow1addr1'; + const addr2 = 'wow1addr2'; + const addr3 = 'wow1addr3'; // Different user + + svc.addressToUser.set(addr1, { userId, socketId: 'sock1' }); + svc.addressToUser.set(addr2, { userId, socketId: 'sock2' }); + svc.addressToUser.set(addr3, { userId: 'otherUser', socketId: 'sock3' }); + + svc.paymentWatchers.set(addr1, setInterval(() => {}, 10000)); + svc.paymentWatchers.set(addr2, setInterval(() => {}, 10000)); + svc.paymentWatchers.set(addr3, setInterval(() => {}, 10000)); + + svc.cleanupUserPayments(userId); + + expect(svc.paymentWatchers.has(addr1)).toBe(false); + expect(svc.paymentWatchers.has(addr2)).toBe(false); + expect(svc.paymentWatchers.has(addr3)).toBe(true); // Different user, not cleaned + + svc.stopPaymentMonitoring(addr3); + }); +}); + +describe('WalletRPCService.checkPaymentStatus', () => { + let svc; + + beforeEach(() => { + svc = new WalletRPCService(debugManagerMock); + }); + + test('returns default status for unknown address', async () => { + const status = await svc.checkPaymentStatus('unknown_address'); + + expect(status.complete).toBe(false); + expect(status.in_mempool).toBe(false); + expect(status.confirmed).toBe(false); + expect(status.amount).toBe(0); + expect(status.confirmations).toBe(0); + }); + + test('checks mempool and confirmed transfers', async () => { + // Setup: create a payment first + svc.addressToUser.set('wow1testaddr', { + userId: 'user1', + socketId: 'sock1', + amount: 100000000000, + addressIndex: 5, + accountIndex: 0, + detected: false, + confirmed: false + }); + + // Mock RPC calls + svc.rpcCall = jest.fn() + .mockResolvedValueOnce({ + result: { + pool: [], + in: [{ + txid: 'txhash123', + amount: 100000000000, + height: 1000 + }] + } + }) + .mockResolvedValueOnce({ + result: { height: 1005 } // Current height for confirmations + }); + + const status = await svc.checkPaymentStatus('wow1testaddr'); + + expect(status.complete).toBe(true); + expect(status.confirmed).toBe(true); + expect(status.amount).toBe(100000000000); + expect(status.confirmations).toBe(5); // 1005 - 1000 + expect(status.txHash).toBe('txhash123'); + }); +}); + +describe('WalletRPCService Health Status', () => { + test('returns health status object with correct fields', () => { + const svc = new WalletRPCService(debugManagerMock); + svc.isHealthy = true; + svc.paymentWatchers = new Map([['addr1', {}], ['addr2', {}]]); + svc.addressToUser = new Map([['addr1', {}]]); + + const status = svc.getHealthStatus(); + + expect(status.healthy).toBe(true); + expect(status.endpoint).toBe(svc.walletEndpoint); + expect(status.activeMonitors).toBe(2); + expect(status.pendingPayments).toBe(1); + }); + + test('reflects unhealthy state', () => { + const svc = new WalletRPCService(debugManagerMock); + svc.isHealthy = false; + + const status = svc.getHealthStatus(); + + expect(status.healthy).toBe(false); + }); +});