mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
Feat: add smirk login
Fix: add security hardening and transaction database commits
This commit is contained in:
@@ -33,3 +33,4 @@ temp/
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
TEMP_TODO.md
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -236,8 +236,14 @@ class ChatHandler {
|
||||
|
||||
this._chatLastSent.set(socket.id, now);
|
||||
|
||||
// Very light sanitization (escape < >)
|
||||
const safe = trimmed.replace(/[<>]/g, c => c === '<' ? '<' : '>').slice(0, 300);
|
||||
// Complete HTML entity escaping to prevent XSS
|
||||
const safe = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.slice(0, 300);
|
||||
const username = socket.id.substring(0, 6);
|
||||
|
||||
// Save message to history (async, don't block broadcast)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user