mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
- Add alertService.js with Resend email alerts for low balance, disconnects, failed payouts - Update admin.html with full dashboard UI (stats, charts, tables, payout retry) - Add wallet balance to /health endpoint - Add getBalance() and checkTransactionStatus() to walletRPCService - Fix payoutRetryService query and add alert integration - Update .env.example with alert config (RESEND_API_KEY, ADMIN_EMAIL, etc) - Reorganize docs: move TODO.md, LOGS_AND_BACKUP.md to docs/ - Extract deployment guide from README to docs/DEPLOY.md - Consolidate TEMP_TODO.md into docs/TODO.md
515 lines
18 KiB
JavaScript
515 lines
18 KiB
JavaScript
const axios = require('axios');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { ExternalServiceError, AppError } = require('../utils/errors');
|
|
|
|
class WalletRPCService {
|
|
constructor(debugManager) {
|
|
this.debugManager = debugManager;
|
|
this.walletEndpoint = process.env.PRIMARY_WALLET_ENDPOINT || 'http://127.0.0.1:34570';
|
|
this.accountIndex = 0;
|
|
this.paymentWatchers = new Map();
|
|
this.addressToUser = new Map();
|
|
this.addressToSocket = new Map();
|
|
this.isHealthy = false;
|
|
}
|
|
|
|
async initialize() {
|
|
try {
|
|
// Test wallet connection
|
|
const response = await this.rpcCall('get_version');
|
|
this.isHealthy = true;
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log('✅ Wallet RPC service initialized:', {
|
|
version: response.result?.version || 'unknown',
|
|
endpoint: this.walletEndpoint
|
|
});
|
|
}
|
|
|
|
// Get wallet height to ensure it's synced
|
|
const heightResponse = await this.rpcCall('get_height');
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log('📊 Wallet height:', heightResponse.result?.height || 0);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
this.isHealthy = false;
|
|
console.error('❌ Wallet RPC connection failed:', error.message);
|
|
console.error(' Make sure wownero-wallet-rpc is running on', this.walletEndpoint);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async rpcCall(method, params = {}) {
|
|
try {
|
|
const response = await axios.post(`${this.walletEndpoint}/json_rpc`, {
|
|
jsonrpc: '2.0',
|
|
id: '0',
|
|
method: method,
|
|
params: params
|
|
}, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
timeout: 10000
|
|
});
|
|
|
|
if (response.data.error) {
|
|
throw new ExternalServiceError(`Wallet RPC responded with error for method ${method}`, {
|
|
safeMessage: 'Wallet RPC call failed.',
|
|
details: response.data.error,
|
|
cause: response.data.error
|
|
});
|
|
}
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
const wrapped = error instanceof ExternalServiceError
|
|
? error
|
|
: new ExternalServiceError(`Wallet RPC call failed (${method})`, {
|
|
safeMessage: 'Wallet RPC call failed.',
|
|
cause: error
|
|
});
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.error(`❌ Wallet RPC call failed (${method}):`, wrapped.message);
|
|
}
|
|
throw wrapped;
|
|
}
|
|
}
|
|
|
|
async createPaymentRequest(amount, description, userId, socketId) {
|
|
try {
|
|
// Create a new subaddress for this payment
|
|
const response = await this.rpcCall('create_address', {
|
|
account_index: this.accountIndex,
|
|
label: `game_${userId}_${Date.now()}`
|
|
});
|
|
|
|
const address = response.result.address;
|
|
const addressIndex = response.result.address_index;
|
|
|
|
// Store mapping for payment monitoring
|
|
const paymentInfo = {
|
|
userId: userId,
|
|
socketId: socketId,
|
|
amount: amount,
|
|
description: description,
|
|
addressIndex: addressIndex,
|
|
accountIndex: this.accountIndex,
|
|
createdAt: new Date(),
|
|
status: 'pending',
|
|
detected: false,
|
|
confirmed: false
|
|
};
|
|
|
|
this.addressToUser.set(address, paymentInfo);
|
|
this.addressToSocket.set(address, socketId);
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log(`💳 Payment address created:`, {
|
|
user: userId,
|
|
address: address.substring(0, 10) + '...',
|
|
amount: amount,
|
|
index: addressIndex
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
id: uuidv4(), // Generate payment ID
|
|
address: address,
|
|
amount: amount,
|
|
description: description,
|
|
addressIndex: addressIndex,
|
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000) // 30 minutes
|
|
};
|
|
} catch (error) {
|
|
const wrapped = error instanceof AppError ? error : new ExternalServiceError('Failed to create payment address', {
|
|
safeMessage: 'Unable to create a payment address at this time.',
|
|
cause: error
|
|
});
|
|
console.error('❌ Failed to create payment address:', wrapped.message);
|
|
throw wrapped;
|
|
}
|
|
}
|
|
|
|
async checkPaymentStatus(address) {
|
|
try {
|
|
const userInfo = this.addressToUser.get(address);
|
|
if (!userInfo) {
|
|
return {
|
|
complete: false,
|
|
in_mempool: false,
|
|
confirmed: false,
|
|
amount: 0,
|
|
confirmations: 0
|
|
};
|
|
}
|
|
|
|
// Get incoming transfers for this specific subaddress
|
|
const response = await this.rpcCall('get_transfers', {
|
|
in: true,
|
|
pending: true,
|
|
failed: false,
|
|
pool: true,
|
|
account_index: userInfo.accountIndex,
|
|
subaddr_indices: [userInfo.addressIndex]
|
|
});
|
|
|
|
let totalReceived = 0;
|
|
let inMempool = false;
|
|
let confirmed = false;
|
|
let confirmations = 0;
|
|
let txHash = null;
|
|
|
|
// Check pending (mempool) transactions
|
|
if (response.result?.pool && response.result.pool.length > 0) {
|
|
for (const tx of response.result.pool) {
|
|
totalReceived += tx.amount;
|
|
inMempool = true;
|
|
txHash = tx.txid;
|
|
|
|
// Mark as detected if this is first time seeing it
|
|
if (!userInfo.detected) {
|
|
userInfo.detected = true;
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log(`🎯 Payment detected (mempool):`, {
|
|
address: address.substring(0, 10) + '...',
|
|
amount: tx.amount,
|
|
txid: txHash
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check confirmed transactions
|
|
if (response.result?.in && response.result.in.length > 0) {
|
|
for (const tx of response.result.in) {
|
|
totalReceived += tx.amount;
|
|
confirmed = true;
|
|
txHash = tx.txid;
|
|
|
|
// Calculate confirmations
|
|
if (tx.height > 0) {
|
|
const heightResp = await this.rpcCall('get_height');
|
|
confirmations = Math.max(confirmations, heightResp.result.height - tx.height);
|
|
}
|
|
|
|
if (!userInfo.confirmed) {
|
|
userInfo.confirmed = true;
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log(`✅ Payment included in block (confirmed):`, {
|
|
address: address.substring(0, 10) + '...',
|
|
confirmations: confirmations,
|
|
txid: txHash
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const paymentComplete = totalReceived >= userInfo.amount;
|
|
|
|
return {
|
|
complete: paymentComplete,
|
|
in_mempool: inMempool,
|
|
confirmed: confirmed,
|
|
amount: totalReceived,
|
|
confirmations: confirmations,
|
|
required: userInfo.amount,
|
|
txHash: txHash
|
|
};
|
|
} catch (error) {
|
|
console.error('❌ Failed to check payment status:', error);
|
|
return {
|
|
complete: false,
|
|
in_mempool: false,
|
|
confirmed: false,
|
|
amount: 0,
|
|
confirmations: 0
|
|
};
|
|
}
|
|
}
|
|
|
|
startPaymentMonitoring(address, callback, interval = 2000) {
|
|
if (this.paymentWatchers.has(address)) {
|
|
return; // Already monitoring
|
|
}
|
|
|
|
const watcher = setInterval(async () => {
|
|
const status = await this.checkPaymentStatus(address);
|
|
|
|
if (status.in_mempool || status.confirmed) {
|
|
callback(status);
|
|
|
|
// Stop monitoring if payment is confirmed
|
|
if (status.confirmed && status.complete) {
|
|
this.stopPaymentMonitoring(address);
|
|
}
|
|
}
|
|
}, interval);
|
|
|
|
this.paymentWatchers.set(address, watcher);
|
|
// Allow process to exit if this is the only remaining handle (useful for tests)
|
|
if (watcher.unref) watcher.unref();
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log(`👁️ Started monitoring payments for:`, address.substring(0, 10) + '...');
|
|
}
|
|
}
|
|
|
|
stopPaymentMonitoring(address) {
|
|
const watcher = this.paymentWatchers.get(address);
|
|
if (watcher) {
|
|
clearInterval(watcher);
|
|
this.paymentWatchers.delete(address);
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log(`🛑 Stopped monitoring:`, address.substring(0, 10) + '...');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Placeholder for future batch payout aggregation.
|
|
* Currently returns early to avoid runtime errors where it's scheduled.
|
|
*/
|
|
async processBatchPayouts() {
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log('⚙️ processBatchPayouts stub invoked (no payouts processed).');
|
|
}
|
|
return { processed: 0 };
|
|
}
|
|
|
|
normalizeAtomicAmount(value) {
|
|
if (value === undefined || value === null) {
|
|
throw new AppError('Payout amount was not provided', {
|
|
safeMessage: 'Invalid payout amount supplied.'
|
|
});
|
|
}
|
|
|
|
if (typeof value === 'bigint') {
|
|
if (value <= 0n) {
|
|
throw new AppError('Payout amount must be greater than zero', {
|
|
safeMessage: 'Invalid payout amount supplied.'
|
|
});
|
|
}
|
|
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
return value.toString();
|
|
}
|
|
return Number(value);
|
|
}
|
|
|
|
const num = Number(value);
|
|
if (!Number.isFinite(num) || num <= 0) {
|
|
throw new AppError('Payout amount must be a positive number', {
|
|
safeMessage: 'Invalid payout amount supplied.'
|
|
});
|
|
}
|
|
|
|
const truncated = Math.trunc(num);
|
|
if (truncated <= 0) {
|
|
throw new AppError('Payout amount rounded below minimum atomic unit', {
|
|
safeMessage: 'Invalid payout amount supplied.'
|
|
});
|
|
}
|
|
|
|
return truncated;
|
|
}
|
|
|
|
/**
|
|
* Get wallet balance for admin monitoring
|
|
* @returns {Object} - { balance: bigint, unlocked_balance: bigint } in atomic units
|
|
*/
|
|
async getBalance() {
|
|
try {
|
|
const response = await this.rpcCall('get_balance', {
|
|
account_index: this.accountIndex
|
|
});
|
|
|
|
return {
|
|
balance: response.result?.balance || 0,
|
|
unlocked_balance: response.result?.unlocked_balance || 0
|
|
};
|
|
} catch (error) {
|
|
console.error('❌ Failed to get wallet balance:', error.message);
|
|
return {
|
|
balance: 0,
|
|
unlocked_balance: 0,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
userId = null,
|
|
gameId = null,
|
|
description = null,
|
|
multiplier = null,
|
|
subaddressIndex = null
|
|
} = {}) {
|
|
try {
|
|
if (!address || typeof address !== 'string') {
|
|
throw new AppError('Payout address was not provided or invalid', {
|
|
safeMessage: 'Invalid payout address supplied.'
|
|
});
|
|
}
|
|
|
|
// 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 = {
|
|
destinations: [{
|
|
amount: normalizedAmount,
|
|
address
|
|
}],
|
|
account_index: this.accountIndex,
|
|
priority: 1,
|
|
get_tx_key: true
|
|
};
|
|
|
|
if (Number.isInteger(subaddressIndex) && subaddressIndex >= 0) {
|
|
transferParams.subaddr_indices = [subaddressIndex];
|
|
}
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log('🚀 Initiating payout transfer', {
|
|
to: address.substring(0, 10) + '...',
|
|
amount: normalizedAmount,
|
|
userId,
|
|
gameId,
|
|
multiplier,
|
|
description
|
|
});
|
|
}
|
|
|
|
const response = await this.rpcCall('transfer', transferParams);
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log('💸 Payout sent successfully', {
|
|
to: address.substring(0, 10) + '...',
|
|
amount: normalizedAmount,
|
|
txHash: response.result.tx_hash,
|
|
fee: response.result.fee
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
txHash: response.result.tx_hash,
|
|
txKey: response.result.tx_key,
|
|
fee: response.result.fee,
|
|
userId,
|
|
gameId,
|
|
amount: normalizedAmount
|
|
};
|
|
} catch (error) {
|
|
const wrapped = error instanceof AppError ? error : new ExternalServiceError('Failed to process payout', {
|
|
safeMessage: 'Unable to send payout at this time.',
|
|
cause: error
|
|
});
|
|
|
|
const meta = { userId, gameId, address: address?.substring?.(0, 10) + '...', amount };
|
|
console.error('❌ Failed to process payout:', wrapped.message, meta);
|
|
throw wrapped;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
this.stopPaymentMonitoring(address);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get service health status
|
|
getHealthStatus() {
|
|
return {
|
|
healthy: this.isHealthy,
|
|
endpoint: this.walletEndpoint,
|
|
activeMonitors: this.paymentWatchers.size,
|
|
pendingPayments: this.addressToUser.size
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = WalletRPCService; |