Files
wownerogue/src/payments/walletRPCService.js
T
jwinterm a81c19c936 feat: admin dashboard, alert service, and docs reorganization
- 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
2026-01-31 20:21:28 -05:00

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;