Feat: add smirk login

Fix: add security hardening and transaction database commits
This commit is contained in:
jwinterm
2026-01-28 17:21:53 -05:00
parent 64e2b45f89
commit 2286d8deff
12 changed files with 990 additions and 67 deletions
+1
View File
@@ -33,3 +33,4 @@ temp/
# OS generated files
.DS_Store
Thumbs.db
TEMP_TODO.md
+1
View File
@@ -73,6 +73,7 @@
<script src="js/ui/transactionHistory.js"></script>
<script src="js/ui/helpModal.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/network/smirkAuth.js"></script>
<script src="js/input/inputHandler.js"></script>
</head>
<body>
+298
View File
@@ -0,0 +1,298 @@
/**
* SmirkAuth - Smirk Wallet Authentication Module
*
* Handles "Login with Smirk" wallet integration for Wownerogue.
* Uses the Smirk browser extension API (window.smirk) for wallet interactions.
*
* Flow:
* 1. Get challenge from server
* 2. Connect to Smirk extension to get public keys
* 3. Sign challenge with wallet
* 4. Verify signature with backend
* 5. Auto-set payout address from wallet
*/
const SmirkAuth = {
/**
* Check if Smirk extension is installed
*/
isAvailable() {
return typeof window.smirk !== 'undefined';
},
/**
* Current link status
*/
_isLinked: false,
_walletAddress: null,
/**
* Main login flow
*/
async login() {
if (!this.isAvailable()) {
throw new Error('Smirk extension not installed. Please install it from your browser extension store.');
}
if (!window.socket || !window.socket.id) {
throw new Error('Not connected to server. Please wait for connection.');
}
const socketId = window.socket.id;
// Step 1: Get challenge from server
const challengeRes = await fetch('/api/auth/smirk/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ socketId })
});
if (!challengeRes.ok) {
const error = await challengeRes.json();
throw new Error(error.message || 'Failed to get challenge from server');
}
const { challenge } = await challengeRes.json();
// Step 2: Connect to Smirk extension
let keys;
try {
keys = await window.smirk.connect();
} catch (smirkError) {
throw new Error('Failed to connect to Smirk wallet: ' + (smirkError.message || 'Unknown error'));
}
if (!keys || !keys.wow) {
throw new Error('Smirk wallet did not return a WOW address');
}
// Step 3: Sign the challenge
let signResult;
try {
signResult = await window.smirk.signMessage(challenge);
} catch (signError) {
throw new Error('Failed to sign challenge: ' + (signError.message || 'Unknown error'));
}
// Find WOW signature
const wowSig = signResult.signatures?.find(s => s.asset === 'wow');
if (!wowSig) {
throw new Error('WOW signature not found in Smirk response');
}
// Step 4: Verify signature with backend
const verifyRes = await fetch('/api/auth/smirk/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
socketId,
challenge,
publicKey: wowSig.publicKey,
signature: wowSig.signature
})
});
if (!verifyRes.ok) {
const error = await verifyRes.json();
throw new Error(error.message || 'Signature verification failed');
}
const verifyData = await verifyRes.json();
// Step 5: Auto-set payout address from wallet
if (keys.wow && window.socket) {
window.socket.emit('address:update', { address: keys.wow });
}
// Update internal state
this._isLinked = true;
this._walletAddress = keys.wow;
return {
success: true,
linked: verifyData.linked,
address: keys.wow
};
},
/**
* Check current link status
*/
async checkStatus() {
if (!window.socket || !window.socket.id) {
return { linked: false, hasPayoutAddress: false };
}
try {
const res = await fetch(`/api/auth/smirk/status?socketId=${encodeURIComponent(window.socket.id)}`);
if (res.ok) {
const data = await res.json();
this._isLinked = data.linked;
return data;
}
} catch (e) {
console.warn('Failed to check Smirk status:', e.message);
}
return { linked: false, hasPayoutAddress: false };
},
/**
* Create and return the Smirk login button element
*/
_createButton() {
const btn = $('<button id="smirkLoginBtn" class="menu-button">')
.css({
'background': '#1a1a4d',
'color': '#fff',
'border': '1px solid #3a3a7d',
'margin-top': '5px',
'cursor': 'pointer',
'font-size': '12px',
'padding': '8px 12px',
'width': '100%',
'text-align': 'center'
})
.text('Connect Smirk Wallet');
return btn;
},
/**
* Update button state
*/
_updateButton(btn, state) {
switch (state) {
case 'connecting':
btn.prop('disabled', true)
.css({ 'background': '#2a2a5d', 'cursor': 'wait' })
.text('Connecting...');
break;
case 'connected':
btn.prop('disabled', true)
.css({ 'background': '#0a5c0a', 'border-color': '#0a8c0a', 'cursor': 'default' })
.text('Smirk Connected');
break;
case 'error':
btn.prop('disabled', false)
.css({ 'background': '#5c0a0a', 'border-color': '#8c0a0a', 'cursor': 'pointer' })
.text('Retry Smirk Connect');
break;
default: // 'ready'
btn.prop('disabled', false)
.css({ 'background': '#1a1a4d', 'border-color': '#3a3a7d', 'cursor': 'pointer' })
.text('Connect Smirk Wallet');
}
},
/**
* Handle button click
*/
async _handleClick(btn) {
this._updateButton(btn, 'connecting');
try {
const result = await this.login();
this._updateButton(btn, 'connected');
// Show success message in chat
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._appendMessage) {
SocketHandlers._appendMessage('status', 'Smirk wallet connected! Payout address set.');
} else {
$('#messages').append($('<li class="status">').text('Smirk wallet connected! Payout address set.'));
}
// Update address button status if available
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._updateAddressButtonStatus) {
SocketHandlers._updateAddressButtonStatus(true);
}
return result;
} catch (err) {
console.error('Smirk login failed:', err);
this._updateButton(btn, 'error');
// Show error message
const errorMsg = 'Smirk login failed: ' + err.message;
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._appendMessage) {
SocketHandlers._appendMessage('error', errorMsg);
} else {
$('#messages').append($('<li class="error">').text(errorMsg));
}
throw err;
}
},
/**
* Initialize the Smirk button in the UI
* Should be called after DOM is ready and socket is connected
*/
init() {
// Only show button if Smirk extension is available
if (!this.isAvailable()) {
console.log('Smirk extension not detected, button will not be shown');
return;
}
// Check if button already exists
if ($('#smirkLoginBtn').length > 0) {
return;
}
// Create button
const btn = this._createButton();
// Add click handler
btn.on('click', () => this._handleClick(btn));
// Insert after the address button (if it exists) or after the help button
const addressBtn = $('#manageAddressButton');
if (addressBtn.length > 0) {
addressBtn.after(btn);
} else {
const helpBtn = $('#helpButton');
if (helpBtn.length > 0) {
helpBtn.after(btn);
} else {
// Fallback: append to status area
$('#status').append(btn);
}
}
// Check if already linked on page load
this.checkStatus().then(status => {
if (status.linked) {
this._updateButton(btn, 'connected');
}
});
console.log('Smirk authentication module initialized');
}
};
// Auto-initialize when DOM is ready
$(document).ready(function() {
// Wait a short moment for socket to connect before initializing
setTimeout(() => {
SmirkAuth.init();
}, 1000);
});
// Also re-check when socket connects (in case of reconnection)
if (typeof window.socket !== 'undefined') {
window.socket.on('connect', function() {
setTimeout(() => {
if (SmirkAuth.isAvailable() && !SmirkAuth._isLinked) {
SmirkAuth.checkStatus().then(status => {
const btn = $('#smirkLoginBtn');
if (btn.length > 0 && status.linked) {
SmirkAuth._updateButton(btn, 'connected');
}
});
}
}, 500);
});
}
+21
View File
@@ -218,6 +218,27 @@ class DatabaseManager {
return await this.pool.connect();
}
/**
* Execute a callback within a database transaction
* Automatically handles BEGIN, COMMIT, and ROLLBACK
* @param {Function} callback - Async function that receives the client
* @returns {Promise<any>} - Result from the callback
*/
async withTransaction(callback) {
const client = await this.getClient();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Close database connection
*/
+82 -40
View File
@@ -797,7 +797,26 @@ class GameModeManager {
if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) {
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound });
if (payoutAmount > 0) {
const reason = treasureFound ? 'escape_with_treasure' : 'escape';
let payoutId = null;
try {
// Step 1: Create pending payout record FIRST (ensures we have a record even if RPC succeeds but server crashes)
const insertResult = await this.db.query(`
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW())
RETURNING id
`, [
game.user_id,
gameId,
game.payout_address,
payoutAmount,
multiplier,
reason
]);
payoutId = insertResult.rows[0].id;
// Step 2: Attempt wallet RPC transfer
const payoutResult = await this.walletService.processPayout({
userId: game.user_id,
gameId,
@@ -807,52 +826,75 @@ class GameModeManager {
description: `Game ${gameId} payout`
});
// Record payout in database
const reason = treasureFound ? 'escape_with_treasure' : 'escape';
await this.db.query(`
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, tx_hash, fee, created_at, processed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
`, [
game.user_id,
gameId,
game.payout_address,
payoutAmount,
multiplier,
reason,
payoutResult.success ? 'completed' : 'failed',
payoutResult.txHash || null,
payoutResult.fee || null
]);
// Update user's total_amount_won
if (payoutResult.success) {
// Step 3: IMMEDIATELY store tx_hash if RPC succeeded (BEFORE transaction)
// This ensures we never lose the tx_hash even if subsequent DB operations fail
// The retry service can then check blockchain status using this hash
if (payoutResult.success && payoutResult.txHash) {
await this.db.query(`
UPDATE users
SET total_amount_won = COALESCE(total_amount_won, 0) + $1,
total_payouts_received = COALESCE(total_payouts_received, 0) + 1
WHERE id = $2
`, [payoutAmount, game.user_id]);
UPDATE payouts
SET tx_hash = $1, fee = $2
WHERE id = $3
`, [payoutResult.txHash, payoutResult.fee || null, payoutId]);
}
// Step 4: Update status and user stats (within transaction for atomicity)
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE payouts
SET status = $1, processed_at = NOW()
WHERE id = $2
`, [
payoutResult.success ? 'completed' : 'failed',
payoutId
]);
// Update user's total_amount_won if successful
if (payoutResult.success) {
await client.query(`
UPDATE users
SET total_amount_won = COALESCE(total_amount_won, 0) + $1,
total_payouts_received = COALESCE(total_payouts_received, 0) + 1
WHERE id = $2
`, [payoutAmount, game.user_id]);
}
});
console.log(`💸 Payout ${payoutResult.success ? 'sent' : 'failed'}: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
} catch (payoutError) {
console.error(`❌ Payout processing failed for game ${gameId}:`, payoutError.message);
// Record failed payout attempt
try {
await this.db.query(`
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, error_message, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'failed', $7, NOW())
`, [
game.user_id,
gameId,
game.payout_address,
payoutAmount,
multiplier,
treasureFound ? 'escape_with_treasure' : 'escape',
payoutError.message
]);
} catch (dbErr) {
console.error('Failed to record payout error:', dbErr.message);
// Update payout record with failure (if it exists)
if (payoutId) {
try {
await this.db.query(`
UPDATE payouts
SET status = 'failed',
error_message = $1,
retry_count = COALESCE(retry_count, 0),
last_error = $1,
last_retry_at = NOW()
WHERE id = $2
`, [payoutError.message, payoutId]);
} catch (dbErr) {
console.error('Failed to update payout error:', dbErr.message);
}
} else {
// Payout record wasn't created, insert failed record
try {
await this.db.query(`
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, error_message, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'failed', $7, NOW())
`, [
game.user_id,
gameId,
game.payout_address,
payoutAmount,
multiplier,
reason,
payoutError.message
]);
} catch (dbErr) {
console.error('Failed to record payout error:', dbErr.message);
}
}
}
}
+181 -2
View File
@@ -1,5 +1,6 @@
const express = require('express');
const path = require('path');
const crypto = require('crypto');
var app = express();
var http = require('http').Server(app);
var io = require('socket.io')(http);
@@ -10,6 +11,7 @@ require('dotenv').config();
// Import payment system components
const DatabaseManager = require('./db/databaseManager');
const WalletRPCService = require('./payments/walletRPCService');
const PayoutRetryService = require('./payments/payoutRetryService');
const GameModeManager = require('./game/gameModeManager');
const RpcService = require('./rpc/rpcService');
const PaymentConfigManager = require('./config/paymentConfig');
@@ -417,6 +419,169 @@ app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => {
}
}));
// =============================================================================
// Smirk Wallet Authentication Endpoints
// =============================================================================
/**
* POST /api/auth/smirk/challenge
* Generate a challenge for Smirk wallet signature verification
* Body: { socketId: string }
*/
app.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => {
const { socketId } = req.body || {};
if (!socketId || typeof socketId !== 'string') {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId is required to generate a challenge.'
});
}
// Generate a cryptographically secure challenge
const challenge = crypto.randomBytes(32).toString('hex');
// Store challenge in database (expires in 5 minutes)
await db.query(`
INSERT INTO smirk_challenges (challenge, socket_id)
VALUES ($1, $2)
`, [challenge, socketId]);
// Clean up old/expired challenges periodically
await db.query(`
DELETE FROM smirk_challenges
WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '1 hour')
`);
res.json({
challenge,
expiresIn: 300 // 5 minutes in seconds
});
}));
/**
* POST /api/auth/smirk/verify
* Verify a Smirk wallet signature and link to user session
* Body: { socketId: string, challenge: string, publicKey: string, signature: string }
*/
app.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => {
const { socketId, challenge, publicKey, signature } = req.body || {};
if (!socketId || !challenge || !publicKey || !signature) {
throw new ValidationError('Missing required fields', {
safeMessage: 'socketId, challenge, publicKey, and signature are required.'
});
}
// 1. Verify challenge exists, is not used, and has not expired
const challengeResult = await db.query(`
SELECT * FROM smirk_challenges
WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW()
`, [challenge, socketId]);
if (challengeResult.rows.length === 0) {
throw new ValidationError('Invalid or expired challenge', {
safeMessage: 'The challenge is invalid, expired, or has already been used.'
});
}
// 2. Mark challenge as used immediately (prevent replay attacks)
await db.query(`
UPDATE smirk_challenges SET used = TRUE WHERE id = $1
`, [challengeResult.rows[0].id]);
// 3. Verify signature using tweetnacl
// The signature should be the challenge signed by the wallet's private key
let isValidSignature = false;
try {
const nacl = require('tweetnacl');
const challengeBytes = Buffer.from(challenge, 'hex');
const signatureBytes = Buffer.from(signature, 'hex');
const publicKeyBytes = Buffer.from(publicKey, 'hex');
// tweetnacl.sign.detached.verify(message, signature, publicKey)
isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
} catch (verifyError) {
console.error('Signature verification error:', verifyError.message);
throw new ValidationError('Signature verification failed', {
safeMessage: 'Unable to verify the wallet signature.'
});
}
if (!isValidSignature) {
throw new ValidationError('Invalid signature', {
safeMessage: 'The wallet signature is invalid.'
});
}
// 4. Link public key to user session
const userResult = await db.query(`
SELECT id FROM users WHERE socket_id = $1
`, [socketId]);
if (userResult.rows.length === 0) {
throw new NotFoundError('User session not found', {
safeMessage: 'No active session found. Please connect to the game first.'
});
}
const userId = userResult.rows[0].id;
// Check if this public key is already linked to another user
const existingLink = await db.query(`
SELECT id FROM users WHERE smirk_public_key = $1 AND id != $2
`, [publicKey, userId]);
if (existingLink.rows.length > 0) {
throw new ValidationError('Wallet already linked', {
safeMessage: 'This wallet is already linked to another account.'
});
}
// Link the wallet to this user
await db.query(`
UPDATE users SET smirk_public_key = $1 WHERE id = $2
`, [publicKey, userId]);
res.json({
success: true,
linked: true,
message: 'Smirk wallet linked successfully'
});
}));
/**
* GET /api/auth/smirk/status
* Check if a session has a linked Smirk wallet
* Query: socketId=string
*/
app.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
const { socketId } = req.query;
if (!socketId) {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId query parameter is required.'
});
}
const result = await db.query(`
SELECT smirk_public_key, payout_address FROM users WHERE socket_id = $1
`, [socketId]);
if (result.rows.length === 0) {
res.json({
linked: false,
hasPayoutAddress: false
});
return;
}
const user = result.rows[0];
res.json({
linked: !!user.smirk_public_key,
hasPayoutAddress: !!user.payout_address
});
}));
// =============================================================================
// Admin API Endpoints (requires ADMIN_API_KEY)
// =============================================================================
@@ -435,8 +600,10 @@ const adminAuth = (req, res, next) => {
}
const providedKey = req.headers['x-admin-key'];
if (!providedKey || providedKey !== adminKey) {
return res.status(401).json({
if (!providedKey ||
providedKey.length !== adminKey.length ||
!crypto.timingSafeEqual(Buffer.from(providedKey), Buffer.from(adminKey))) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or missing X-Admin-Key header.'
});
@@ -976,6 +1143,18 @@ async function startServer() {
console.error('Error in batch payout processing:', error);
}
}, payoutIntervalMs);
// Start payout retry service for failed/stuck payouts
const maxRetries = Number(process.env.PAYOUT_MAX_RETRIES) || 3;
const retryIntervalMs = Number(process.env.PAYOUT_RETRY_INTERVAL_MS) || 300000; // 5 minutes
const payoutRetryService = new PayoutRetryService({
db: databaseManager,
walletService: walletRPCService,
debugManager,
maxRetries,
retryIntervalMs
});
payoutRetryService.start();
}
} catch (error) {
+12
View File
@@ -0,0 +1,12 @@
-- Migration 008: Add UNIQUE constraints on tx_hash for idempotency
-- Prevents duplicate transaction processing which could lead to double-crediting or double-payouts
-- Unique constraint on payments.tx_hash (partial index - only non-null values)
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_tx_hash_unique
ON payments(tx_hash)
WHERE tx_hash IS NOT NULL;
-- Unique constraint on payouts.tx_hash (partial index - only non-null values)
CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_tx_hash_unique
ON payouts(tx_hash)
WHERE tx_hash IS NOT NULL;
+33
View File
@@ -0,0 +1,33 @@
-- Migration 009: Smirk wallet integration
-- Adds support for "Login with Smirk" wallet authentication
-- Add Smirk public key to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS smirk_public_key VARCHAR(128);
-- Unique constraint on Smirk public key (one wallet per user account)
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_smirk_public_key
ON users(smirk_public_key)
WHERE smirk_public_key IS NOT NULL;
-- Challenge storage for authentication flow
-- Challenges expire after 5 minutes and are single-use
CREATE TABLE IF NOT EXISTS smirk_challenges (
id SERIAL PRIMARY KEY,
challenge VARCHAR(64) NOT NULL UNIQUE,
socket_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP DEFAULT (NOW() + INTERVAL '5 minutes'),
used BOOLEAN DEFAULT FALSE
);
-- Index for looking up challenges by socket
CREATE INDEX IF NOT EXISTS idx_smirk_challenges_socket
ON smirk_challenges(socket_id);
-- Index for cleanup of expired challenges
CREATE INDEX IF NOT EXISTS idx_smirk_challenges_expires
ON smirk_challenges(expires_at);
-- Cleanup function for expired challenges (run periodically)
-- DELETE FROM smirk_challenges WHERE expires_at < NOW() OR used = TRUE;
+8 -2
View File
@@ -236,8 +236,14 @@ class ChatHandler {
this._chatLastSent.set(socket.id, now);
// Very light sanitization (escape < >)
const safe = trimmed.replace(/[<>]/g, c => c === '<' ? '&lt;' : '&gt;').slice(0, 300);
// Complete HTML entity escaping to prevent XSS
const safe = trimmed
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.slice(0, 300);
const username = socket.id.substring(0, 6);
// Save message to history (async, don't block broadcast)
+45 -23
View File
@@ -180,34 +180,56 @@ class SessionManager {
creditsToAdd = parseInt(match[1], 10) || 10;
}
// Add credits to user
const updateResult = await this.db.query(`
UPDATE users
SET credits = credits + $1,
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1
WHERE id = $2
RETURNING credits
`, [creditsToAdd, userId]);
// CRITICAL: Use transaction with row lock to prevent double-credit race condition
// This ensures only one instance can process a payment at a time
const recovered = await this.db.withTransaction(async (client) => {
// Lock the payment row and re-check if it's still unprocessed
const lockResult = await client.query(`
SELECT id, credits_purchased
FROM payments
WHERE id = $1
FOR UPDATE
`, [payment.id]);
const newBalance = updateResult.rows[0]?.credits ?? 0;
// If already processed (by another instance), skip
if (!lockResult.rows[0] || (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0)) {
return null; // Already processed
}
// Mark payment as processed
await this.db.query(`
UPDATE payments
SET credits_purchased = $1
WHERE id = $2
`, [creditsToAdd, payment.id]);
// Add credits to user
const updateResult = await client.query(`
UPDATE users
SET credits = credits + $1,
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1
WHERE id = $2
RETURNING credits
`, [creditsToAdd, userId]);
// Record credit transaction
await this.db.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, 'package_purchase_recovered', $3, 'purchase')
`, [userId, creditsToAdd, newBalance]);
const newBalance = updateResult.rows[0]?.credits ?? 0;
result.creditsRecovered += creditsToAdd;
result.paymentsProcessed++;
// Mark payment as processed (atomically with credit add)
await client.query(`
UPDATE payments
SET credits_purchased = $1
WHERE id = $2
`, [creditsToAdd, payment.id]);
console.log(`💰 [PaymentRecovery] Recovered ${creditsToAdd} credits for user ${userId} from payment ${payment.id}`);
// Record credit transaction
await client.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, 'package_purchase_recovered', $3, 'purchase')
`, [userId, creditsToAdd, newBalance]);
return { creditsToAdd, newBalance };
});
if (recovered) {
result.creditsRecovered += recovered.creditsToAdd;
result.paymentsProcessed++;
console.log(`💰 [PaymentRecovery] Recovered ${recovered.creditsToAdd} credits for user ${userId} from payment ${payment.id}`);
} else {
console.log(`ℹ️ [PaymentRecovery] Payment ${payment.id} already processed by another instance`);
}
} catch (paymentError) {
console.error(`[PaymentRecovery] Failed to process payment ${payment.id}:`, paymentError.message);
}
+232
View File
@@ -0,0 +1,232 @@
/**
* PayoutRetryService - Handles retrying failed payouts
*
* Processes payouts that have status 'pending' (stuck) or 'failed' (retryable)
* with retry_count below the maximum threshold.
*/
const { AppError } = require('../utils/errors');
class PayoutRetryService {
constructor({ db, walletService, debugManager, maxRetries = 3, retryIntervalMs = 300000 }) {
this.db = db;
this.walletService = walletService;
this.debugManager = debugManager;
this.maxRetries = maxRetries;
this.retryIntervalMs = retryIntervalMs; // Default: 5 minutes
this.isProcessing = false;
this.retryTimer = null;
}
/**
* Start the retry service
*/
start() {
if (this.retryTimer) {
console.log('⚠️ Payout retry service already running');
return;
}
console.log(`🔄 Starting payout retry service (interval: ${this.retryIntervalMs / 1000}s, max retries: ${this.maxRetries})`);
// Run immediately on start
this.processRetries();
// Then run on interval
this.retryTimer = setInterval(() => this.processRetries(), this.retryIntervalMs);
}
/**
* Stop the retry service
*/
stop() {
if (this.retryTimer) {
clearInterval(this.retryTimer);
this.retryTimer = null;
console.log('🛑 Payout retry service stopped');
}
}
/**
* Process all retryable payouts
*/
async processRetries() {
if (this.isProcessing) {
if (this.debugManager?.CONSOLE_LOGGING) {
console.log('⏳ Payout retry already in progress, skipping...');
}
return;
}
this.isProcessing = true;
try {
// Find payouts that need retry:
// 1. Status is 'pending' (stuck - RPC may have succeeded but DB update failed)
// 2. Status is 'failed' with retry_count < maxRetries
// Exclude very recent failures (wait at least 1 minute before retry)
const result = await this.db.query(`
SELECT p.*, g.payout_address as game_payout_address
FROM payouts p
LEFT JOIN games g ON p.game_id = g.id
WHERE (
(p.status = 'pending' AND p.created_at < NOW() - INTERVAL '2 minutes')
OR (p.status = 'failed' AND p.retry_count < $1 AND (p.last_retry_at IS NULL OR p.last_retry_at < NOW() - INTERVAL '1 minute'))
)
AND p.status != 'permanently_failed'
ORDER BY p.created_at ASC
LIMIT 10
`, [this.maxRetries]);
if (result.rows.length === 0) {
if (this.debugManager?.CONSOLE_LOGGING) {
console.log('✅ No payouts to retry');
}
return;
}
console.log(`🔄 Processing ${result.rows.length} payout(s) for retry`);
for (const payout of result.rows) {
await this.retryPayout(payout);
}
} catch (error) {
console.error('❌ Error in payout retry process:', error.message);
} finally {
this.isProcessing = false;
}
}
/**
* Retry a single payout
*/
async retryPayout(payout) {
const { id, user_id, game_id, payout_address, amount, multiplier, reason, retry_count, status } = payout;
console.log(`🔄 Retrying payout ${id} (attempt ${(retry_count || 0) + 1}/${this.maxRetries})`);
try {
// CRITICAL: If payout has a tx_hash, check blockchain BEFORE retrying
// This prevents double-spending if RPC succeeded but DB update failed
if (payout.tx_hash) {
try {
const txStatus = await this.walletService.checkTransactionStatus(payout.tx_hash);
if (txStatus.exists) {
// Transaction is in blockchain/mempool - mark as completed, don't retry!
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE payouts
SET status = 'completed', processed_at = NOW()
WHERE id = $1
`, [id]);
// Update user stats (may have been missed if original DB update failed)
await client.query(`
UPDATE users
SET total_amount_won = COALESCE(total_amount_won, 0) + $1,
total_payouts_received = COALESCE(total_payouts_received, 0) + 1
WHERE id = $2 AND NOT EXISTS (
SELECT 1 FROM payouts
WHERE id = $3 AND status = 'completed' AND processed_at IS NOT NULL
)
`, [amount, user_id, id]);
});
console.log(`✅ Payout ${id} already in blockchain (${txStatus.confirmations} confirmations), marked completed`);
return;
}
// tx_hash exists in DB but NOT in blockchain - safe to retry
console.log(`⚠️ Payout ${id} has tx_hash but not found in blockchain, will retry`);
} catch (checkError) {
// If we can't check blockchain status, don't risk double-spend - mark for manual review
console.error(`❌ Cannot verify tx_hash for payout ${id}, marking for manual review:`, checkError.message);
await this.db.query(`
UPDATE payouts
SET status = 'needs_review',
last_error = $1,
last_retry_at = NOW()
WHERE id = $2
`, [`Cannot verify blockchain status: ${checkError.message}`, id]);
return;
}
}
// Attempt the payout
const address = payout_address || payout.game_payout_address;
if (!address) {
throw new AppError(`No payout address found for payout ${id}`);
}
const payoutResult = await this.walletService.processPayout({
userId: user_id,
gameId: game_id,
address,
amount,
multiplier,
description: `Retry payout ${id} for game ${game_id}`
});
// IMMEDIATELY store tx_hash (same pattern as gameModeManager)
if (payoutResult.success && payoutResult.txHash) {
await this.db.query(`
UPDATE payouts SET tx_hash = $1, fee = $2 WHERE id = $3
`, [payoutResult.txHash, payoutResult.fee, id]);
}
// Update payout record with success
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE payouts
SET status = 'completed',
processed_at = NOW(),
retry_count = COALESCE(retry_count, 0) + 1,
last_retry_at = NOW()
WHERE id = $1
`, [id]);
// Update user stats
await client.query(`
UPDATE users
SET total_amount_won = COALESCE(total_amount_won, 0) + $1,
total_payouts_received = COALESCE(total_payouts_received, 0) + 1
WHERE id = $2
`, [amount, user_id]);
});
console.log(`✅ Payout ${id} retry succeeded: ${payoutResult.txHash}`);
} catch (error) {
const newRetryCount = (retry_count || 0) + 1;
const newStatus = newRetryCount >= this.maxRetries ? 'permanently_failed' : 'failed';
await this.db.query(`
UPDATE payouts
SET status = $1,
retry_count = $2,
last_error = $3,
last_retry_at = NOW()
WHERE id = $4
`, [newStatus, newRetryCount, error.message, id]);
if (newStatus === 'permanently_failed') {
console.error(`❌ Payout ${id} permanently failed after ${newRetryCount} attempts: ${error.message}`);
} else {
console.warn(`⚠️ Payout ${id} retry failed (attempt ${newRetryCount}/${this.maxRetries}): ${error.message}`);
}
}
}
/**
* Get retry service status
*/
getStatus() {
return {
running: !!this.retryTimer,
isProcessing: this.isProcessing,
maxRetries: this.maxRetries,
retryIntervalMs: this.retryIntervalMs
};
}
}
module.exports = PayoutRetryService;
+76
View File
@@ -318,6 +318,33 @@ class WalletRPCService {
return truncated;
}
/**
* Validate a payout address using wallet RPC
* @param {string} address - The address to validate
* @returns {Object} - { valid: boolean, integrated: boolean, subaddress: boolean }
*/
async validateAddress(address) {
try {
if (!address || typeof address !== 'string') {
return { valid: false, error: 'Address is empty or not a string' };
}
const response = await this.rpcCall('validate_address', {
address: address.trim(),
any_net_type: false // Strict - must match wallet's network
});
return {
valid: response.result?.valid === true,
integrated: response.result?.integrated === true,
subaddress: response.result?.subaddress === true,
nettype: response.result?.nettype
};
} catch (error) {
return { valid: false, error: error.message };
}
}
async processPayout({
address,
amount,
@@ -334,6 +361,14 @@ class WalletRPCService {
});
}
// Validate address with wallet RPC before attempting transfer
const validation = await this.validateAddress(address);
if (!validation.valid) {
throw new AppError(`Invalid payout address: ${validation.error || 'validation failed'}`, {
safeMessage: 'The payout address is invalid or not recognized by the wallet.'
});
}
const normalizedAmount = this.normalizeAtomicAmount(amount);
const transferParams = {
@@ -393,6 +428,47 @@ class WalletRPCService {
}
}
/**
* Check if a transaction exists in the blockchain or mempool
* Used by retry service to avoid double-spending
* @param {string} txHash - The transaction hash to check
* @returns {Object} - { exists: boolean, confirmed: boolean, in_mempool: boolean }
*/
async checkTransactionStatus(txHash) {
try {
if (!txHash || typeof txHash !== 'string') {
return { exists: false, confirmed: false, in_mempool: false };
}
// Use get_transfer_by_txid to check if transaction exists
const response = await this.rpcCall('get_transfer_by_txid', {
txid: txHash,
account_index: this.accountIndex
});
if (response.result?.transfer) {
const transfer = response.result.transfer;
return {
exists: true,
confirmed: transfer.confirmations > 0,
in_mempool: transfer.confirmations === 0,
confirmations: transfer.confirmations || 0,
amount: transfer.amount,
fee: transfer.fee
};
}
return { exists: false, confirmed: false, in_mempool: false };
} catch (error) {
// If the RPC fails with "Transaction not found" style error, tx doesn't exist
if (error.message?.includes('not found') || error.details?.message?.includes('not found')) {
return { exists: false, confirmed: false, in_mempool: false };
}
// For other errors, we can't be sure, so throw
throw error;
}
}
cleanupUserPayments(userId) {
for (const [address, userInfo] of this.addressToUser.entries()) {
if (userInfo.userId === userId || userInfo.socketId === userId) {