const axios = require('axios'); const { v4: uuidv4 } = require('uuid'); const { ExternalServiceError, AppError } = require('../utils/errors'); const money = require('../money/atomic'); const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i; const { DigestAuthClient } = require('./digestAuth'); const XMR_ADDRESS_PATTERN = /^(?:4|8|5|7|9|B)[1-9A-HJ-NP-Za-km-z]{90,110}$/; const WOW_ADDRESS_PATTERN = /^(?:(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})$/; const KNOWN_NETWORKS = new Set(['mainnet', 'stagenet', 'testnet']); function incompleteReceiptEvidence() { return new ExternalServiceError('Wallet receipt response is incomplete.', { code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE', safeMessage: 'Payment status is unavailable.' }); } function validWalletResult(response, fields) { const result = response?.result; return result && typeof result === 'object' && !Array.isArray(result) && fields.every(field => !Object.prototype.hasOwnProperty.call(result, field) || Array.isArray(result[field])); } function normalizeNetwork(value) { const normalized = String(value || '').trim().toLowerCase(); return KNOWN_NETWORKS.has(normalized) ? normalized : null; } function endpointLabel(value) { try { const endpoint = new URL(value); endpoint.username = ''; endpoint.password = ''; return endpoint.toString().replace(/\/$/, ''); } catch (_) { return '[invalid endpoint]'; } } function cryptoTypeFromAddress(address) { const value = String(address || '').trim(); if (XMR_ADDRESS_PATTERN.test(value)) return 'XMR'; if (WOW_ADDRESS_PATTERN.test(value)) return 'WOW'; return null; } class WalletRPCService { constructor(debugManager, options = {}) { const env = options.env || process.env; this.debugManager = debugManager; this.http = options.http || axios; this.walletEndpoint = options.walletEndpoint || env.PRIMARY_WALLET_ENDPOINT || 'http://127.0.0.1:34570'; this.authRequired = options.authRequired === undefined ? String(env.NODE_ENV || '').toLowerCase() === 'production' : options.authRequired === true; const rpcUser = options.rpcUser || env.WALLET_RPC_USER; const rpcPassword = options.rpcPassword || env.WALLET_RPC_PASSWORD; // Monero wallet-rpc uses HTTP Digest auth. Keep unauthenticated mode available for // development fixtures; production validation requires an intentional protected setup. this.walletAuth = rpcUser && rpcPassword ? { username: rpcUser, password: rpcPassword } : null; this.rpcHttp = this.walletAuth ? new DigestAuthClient(this.http, this.walletAuth) : this.http; this.authMaxAgeMs = Math.max(1000, Number(options.authMaxAgeMs) || 30000); this.authentication = { verified: false, reason: this.walletAuth ? 'not_checked' : 'credentials_missing', checkedAt: 0 }; this.accountIndex = 0; this.paymentWatchers = new Map(); this.addressToUser = new Map(); this.addressToSocket = new Map(); this.isHealthy = false; this.lastHealthSuccessAt = 0; this.lastHealthFailureAt = 0; const configuredCrypto = String(options.cryptoType || env.CRYPTO_TYPE || 'WOW').trim().toUpperCase(); const configuredNetwork = normalizeNetwork(options.network || env.MONERO_NETWORK) || 'mainnet'; this.expectedIdentity = { cryptoType: configuredCrypto, network: configuredNetwork }; this.identityRequired = options.identityRequired === undefined ? String(env.NODE_ENV || '').toLowerCase() === 'production' : options.identityRequired === true; this.identityMaxAgeMs = Math.max(1000, Number(options.identityMaxAgeMs) || 30000); this.networkIdentity = { verified: false, expected: { ...this.expectedIdentity }, actual: null, reason: 'not_checked', checkedAt: 0 }; // The production composition root injects the single payout/readiness predicate here. // Isolated diagnostics and the explicitly guarded stagenet canary keep the permissive // default unless they deliberately inject a stricter boundary. this.transferAllowed = typeof options.transferAllowed === 'function' ? options.transferAllowed : () => true; const configuredConfirmations = Number(options.minConfirmations ?? 1); this.minConfirmations = Number.isSafeInteger(configuredConfirmations) && configuredConfirmations >= 1 ? configuredConfirmations : 1; } async initialize() { try { await this.ensureAuthentication({ force: true }); const response = await this.rpcCall('get_version'); if (!response?.result) throw new Error('Wallet version response is invalid.'); if (this.debugManager?.CONSOLE_LOGGING) { console.log('✅ Wallet RPC service initialized:', { version: response.result?.version || 'unknown' }); } const heightResponse = await this.rpcCall('get_height'); const height = Number(heightResponse.result?.height); if (!Number.isSafeInteger(height) || height <= 0) { throw new Error('Wallet returned an invalid height.'); } const identity = await this.verifyNetworkIdentity({ force: true }); if (this.identityRequired && !identity.verified) { throw this._identityError(); } // Transport success alone must not revive a production wallet that failed identity // verification. initialize()/ensureNetworkIdentity() promote it after verification. this.isHealthy = (!this.identityRequired || this.isIdentityFresh()) && (!this.authRequired || this.isAuthenticationFresh()); if (this.debugManager?.CONSOLE_LOGGING) { console.log('📊 Wallet height:', height); } return true; } catch (error) { this.isHealthy = false; console.error('❌ Wallet RPC initialization failed:', error?.code || 'wallet_unavailable'); return false; } } _identityError() { return new ExternalServiceError('Wallet identity does not match the configured chain/network.', { code: 'WALLET_IDENTITY_MISMATCH', safeMessage: 'Wallet service is unavailable due to a network configuration mismatch.' }); } _authenticationError(reason = this.authentication.reason) { const notEnforced = reason === 'not_enforced'; return new ExternalServiceError( notEnforced ? 'Wallet RPC does not enforce configured Digest authentication.' : 'Wallet RPC authentication could not be verified.', { statusCode: 503, code: notEnforced ? 'WALLET_RPC_AUTH_NOT_ENFORCED' : 'WALLET_RPC_AUTH_UNVERIFIED', safeMessage: 'Wallet service authentication is unavailable.' } ); } async ensureAuthentication({ force = false } = {}) { if (!this.authRequired) return this.authentication; if (!this.walletAuth || typeof this.rpcHttp?.verifyEnforced !== 'function') { this.authentication = { verified: false, reason: 'credentials_missing', checkedAt: Date.now() }; this.isHealthy = false; throw this._authenticationError(); } if (!force && this.isAuthenticationFresh()) return this.authentication; const url = `${this.walletEndpoint}/json_rpc`; const body = { jsonrpc: '2.0', id: 'auth-probe', method: 'get_version', params: {} }; const requestConfig = { headers: { 'Content-Type': 'application/json' }, timeout: 10000 }; try { await this.rpcHttp.verifyEnforced(url, body, requestConfig); const authenticated = await this.rpcHttp.post(url, body, requestConfig); if (!authenticated?.data?.result || authenticated.data.error) { throw new Error('Authenticated wallet version response is invalid.'); } this.authentication = { verified: true, reason: null, checkedAt: Date.now() }; return this.authentication; } catch (error) { const reason = error?.code === 'WALLET_RPC_AUTH_NOT_ENFORCED' ? 'not_enforced' : 'unverified'; this.authentication = { verified: false, reason, checkedAt: Date.now() }; this.isHealthy = false; throw this._authenticationError(reason); } } isAuthenticationFresh() { return this.authentication.verified === true && (Date.now() - this.authentication.checkedAt) <= this.authMaxAgeMs; } async verifyNetworkIdentity({ force = false } = {}) { if (!force && this.networkIdentity.verified && (Date.now() - this.networkIdentity.checkedAt) <= this.identityMaxAgeMs) { return this.networkIdentity; } try { const addressResponse = await this.rpcCall('get_address', { account_index: this.accountIndex, address_index: [0] }); // The primary address is used only in this stack frame. Never retain it in health // state or logs; it is identity evidence, not public diagnostics. const address = String(addressResponse.result?.address || addressResponse.result?.addresses?.[0]?.address || '').trim(); const validationResponse = await this.rpcCall('validate_address', { address, any_net_type: true }); const validation = validationResponse.result || {}; const actual = { cryptoType: cryptoTypeFromAddress(address), network: normalizeNetwork(validation.nettype) }; let reason = null; if (validation.valid !== true || !actual.cryptoType || !actual.network) { reason = 'identity_unavailable'; } else if (actual.cryptoType !== this.expectedIdentity.cryptoType) { reason = 'chain_mismatch'; } else if (actual.network !== this.expectedIdentity.network) { reason = 'network_mismatch'; } this.networkIdentity = { verified: reason === null, expected: { ...this.expectedIdentity }, actual, reason, checkedAt: Date.now() }; if (this.identityRequired && reason) this.isHealthy = false; return this.networkIdentity; } catch (error) { this.networkIdentity = { verified: false, expected: { ...this.expectedIdentity }, actual: null, reason: 'rpc_unavailable', checkedAt: Date.now() }; throw error; } } async ensureNetworkIdentity({ force = false } = {}) { await this.ensureAuthentication({ force }); if (!this.identityRequired) return this.networkIdentity; let identity; try { identity = await this.verifyNetworkIdentity({ force }); } catch (_) { this.isHealthy = false; throw this._identityError(); } if (!identity.verified) { this.isHealthy = false; throw this._identityError(); } this.isHealthy = true; return identity; } _assertTransferAllowed() { if (!this.transferAllowed()) { throw new AppError('Wallet transfer blocked by the payout safety gate.', { statusCode: 503, code: 'PAYOUT_DISPATCH_DISABLED', safeMessage: 'Payout transfers are currently disabled.' }); } } getIdentityStatus() { return { required: this.identityRequired, verified: this.isIdentityFresh(), expected: { ...this.expectedIdentity }, actual: this.networkIdentity.actual ? { ...this.networkIdentity.actual } : null, reason: this.networkIdentity.reason || 'not_checked', checkedAt: Number(this.networkIdentity.checkedAt || 0) }; } isIdentityFresh() { return this.networkIdentity.verified === true && (Date.now() - this.networkIdentity.checkedAt) <= this.identityMaxAgeMs; } async rpcCall(method, params = {}) { try { const requestConfig = { headers: { 'Content-Type': 'application/json' }, timeout: 10000 }; const response = await this.rpcHttp.post(`${this.walletEndpoint}/json_rpc`, { jsonrpc: '2.0', id: '0', method: method, params: params }, requestConfig); 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 }); } this.isHealthy = (!this.identityRequired || this.isIdentityFresh()) && (!this.authRequired || this.isAuthenticationFresh()); this.lastHealthSuccessAt = Date.now(); return response.data; } catch (error) { this.isHealthy = false; this.lastHealthFailureAt = Date.now(); 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 { await this.ensureNetworkIdentity(); 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; // Payment monitoring resolves an address back to its payer and invoice. 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(), address: address, amount: amount, description: description, addressIndex: addressIndex, expiresAt: new Date(Date.now() + 30 * 60 * 1000) }; } 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) { throw new ExternalServiceError('Payment address is not registered for wallet receipt inspection.', { code: 'WALLET_PAYMENT_ADDRESS_UNAVAILABLE', safeMessage: 'Payment status is unavailable.' }); } await this.ensureNetworkIdentity(); // `get_transfers` supplies transaction state (pool, double-spend, timelock and // confirmation metadata), but it does not expose a stable identity for each owned // output. `incoming_transfers` supplies that output-level identity (`pubkey` and // `global_index`). Entitlements require both views to agree for this subaddress. const response = await this.rpcCall('get_transfers', { in: true, pending: true, failed: false, pool: true, account_index: userInfo.accountIndex, subaddr_indices: [userInfo.addressIndex] }); // Wownero's epee serializer omits empty std::list fields. A successful result {} // is valid zero evidence; a present non-array list or missing result is not. if (!validWalletResult(response, ['in', 'pool'])) throw incompleteReceiptEvidence(); const outputResponse = await this.rpcCall('incoming_transfers', { transfer_type: 'all', account_index: userInfo.accountIndex, subaddr_indices: [userInfo.addressIndex] }); if (!validWalletResult(outputResponse, ['transfers'])) throw incompleteReceiptEvidence(); let confirmedReceived = 0n; let poolReceived = 0n; let inMempool = false; let confirmed = false; let confirmations = 0; const confirmedReceipts = new Map(); const receiptAddressIndex = (tx) => { const raw = tx?.subaddr_index; if (Number.isInteger(raw) && raw >= 0) return raw; if (raw && Number.isInteger(raw.minor) && raw.minor >= 0) return raw.minor; return null; }; const receiptAccountIndex = (tx) => { const raw = tx?.subaddr_index; return raw && Number.isInteger(raw.major) && raw.major >= 0 ? raw.major : null; }; const receiptOutputId = (tx) => { const pubkey = String(tx?.pubkey || '').trim().toLowerCase(); if (/^[0-9a-f]{64}$/.test(pubkey)) return pubkey; const globalIndex = tx?.global_index; if (typeof globalIndex === 'number' && !Number.isSafeInteger(globalIndex)) return null; const canonical = String(globalIndex ?? '').trim(); if (!/^\d+$/.test(canonical)) return null; const parsed = BigInt(canonical); if (parsed > 18446744073709551615n) return null; return `global:${parsed.toString()}`; }; const hasSafeUnlockTime = (tx) => { if (tx?.unlock_time == null) return false; const raw = tx.unlock_time; if (typeof raw === 'number') return Number.isSafeInteger(raw) && raw === 0; if (typeof raw === 'bigint') return raw === 0n; const canonical = String(raw).trim(); return /^\d+$/.test(canonical) && BigInt(canonical) === 0n; }; const hasValidUnlockTime = (tx) => { const raw = tx?.unlock_time; if (typeof raw === 'number') return Number.isSafeInteger(raw) && raw >= 0; if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return false; return BigInt(raw) <= 18446744073709551615n; }; const belongsToRequestedSubaddress = (tx) => { const direct = receiptAddressIndex(tx); if (direct !== null) return direct === userInfo.addressIndex; const indices = Array.isArray(tx?.subaddr_indices) ? tx.subaddr_indices : []; return indices.some(index => { if (Number.isInteger(index)) return index === userInfo.addressIndex; return index && Number.isInteger(index.minor) && index.minor === userInfo.addressIndex && (!Number.isInteger(index.major) || index.major === userInfo.accountIndex); }); }; if (response.result?.pool && response.result.pool.length > 0) { for (const tx of response.result.pool) { poolReceived += money.toBig(tx.amount); inMempool = true; if (!userInfo.detected) { userInfo.detected = true; if (this.debugManager?.CONSOLE_LOGGING) { console.log(`🎯 Payment detected (mempool):`, { address: address.substring(0, 10) + '...', amount: money.toSafe(money.toBig(tx.amount)), txid: tx.txid }); } } } } // Build a fail-closed transaction-state index. A chain output is never enough on its // own: its transaction must also appear as a safe, mined incoming transfer for the // exact requested subaddress. const confirmedTx = new Map(); const incomingTx = new Map(); for (const tx of (response.result?.in || [])) { const txHash = String(tx?.txid || '').trim().toLowerCase(); if (!TX_HASH_PATTERN.test(txHash)) throw incompleteReceiptEvidence(); const height = Number(tx?.height); const accountIndex = receiptAccountIndex(tx); const reportedConfirmations = Number(tx?.confirmations); let transactionAmount = null; try { transactionAmount = money.toBig(tx?.amount); } catch (_) { /* rejected below */ } if (!Number.isSafeInteger(height) || height < 0 || !Number.isSafeInteger(reportedConfirmations) || reportedConfirmations < 0 || !hasValidUnlockTime(tx) || typeof tx?.double_spend_seen !== 'boolean' || transactionAmount === null || transactionAmount <= 0n || (receiptAddressIndex(tx) === null && !Array.isArray(tx?.subaddr_indices))) { throw incompleteReceiptEvidence(); } if (!belongsToRequestedSubaddress(tx) || (accountIndex !== null && accountIndex !== userInfo.accountIndex) ) continue; incomingTx.set(txHash, { height, amount: transactionAmount }); if (tx.double_spend_seen !== false || !hasSafeUnlockTime(tx) || height <= 0 || reportedConfirmations < this.minConfirmations ) { continue; } confirmedTx.set(txHash, { height, confirmations: reportedConfirmations, remainingAmount: transactionAmount }); } const observedConfirmedTx = new Set(); for (const output of (outputResponse.result?.transfers || [])) { const txHash = String(output?.tx_hash || '').trim().toLowerCase(); if (!TX_HASH_PATTERN.test(txHash)) throw incompleteReceiptEvidence(); const txState = confirmedTx.get(txHash); const addressIndex = receiptAddressIndex(output); const accountIndex = receiptAccountIndex(output); const outputId = receiptOutputId(output); const blockHeight = Number(output?.block_height); let amount; try { amount = money.toBig(output?.amount); } catch (_) { /* rejected below */ } if (addressIndex === null || !Number.isSafeInteger(blockHeight) || blockHeight <= 0 || amount === undefined || amount <= 0n) { throw incompleteReceiptEvidence(); } if (addressIndex !== userInfo.addressIndex || (accountIndex !== null && accountIndex !== userInfo.accountIndex)) continue; const incomingState = incomingTx.get(txHash); if (!incomingState || blockHeight !== incomingState.height || amount > incomingState.amount) throw incompleteReceiptEvidence(); if (!txState) continue; observedConfirmedTx.add(txHash); if (!outputId) throw incompleteReceiptEvidence(); if (output?.frozen === true) continue; const receiptKey = `${txHash}:${outputId}`; const priorReceipt = confirmedReceipts.get(receiptKey); if (priorReceipt) { if (priorReceipt.amount !== amount.toString()) throw incompleteReceiptEvidence(); continue; } if (amount > txState.remainingAmount) throw incompleteReceiptEvidence(); // RPC responses may repeat the same output; replaying identical evidence must // never increase confirmed coverage. confirmedReceipts.set(receiptKey, { evidenceType: 'chain_output', evidenceId: receiptKey, providerId: 'native-monero', txHash, outputId, addressIndex, amount: amount.toString(), confirmed: true }); confirmedReceived += amount; txState.remainingAmount -= amount; } if (Array.from(confirmedTx.keys()).some(txHash => !observedConfirmedTx.has(txHash))) { throw incompleteReceiptEvidence(); } confirmed = confirmedReceipts.size > 0; if (confirmed) { // Report the least-confirmed counted receipt, never the oldest/top value. const depths = Array.from(confirmedReceipts.values()).map(receipt => { const state = confirmedTx.get(receipt.txHash); return state.confirmations; }); confirmations = Math.min(...depths); if (!userInfo.confirmed) { userInfo.confirmed = true; if (this.debugManager?.CONSOLE_LOGGING) { console.log(`✅ Payment included in block (confirmed):`, { address: address.substring(0, 10) + '...', confirmations, txid: Array.from(confirmedReceipts.values())[0].txHash }); } } } const requiredAmount = money.toBig(userInfo.amount); // Pool transfers are useful detection/UI evidence only. Entitlements are authorized // exclusively by confirmed receipts, so an old 1-atomic confirmed transfer plus an // unconfirmed pool top-up can never satisfy the invoice. const paymentComplete = confirmedReceived >= requiredAmount; const receipts = Array.from(confirmedReceipts.values()) .sort((left, right) => left.evidenceId.localeCompare(right.evidenceId)); return { complete: paymentComplete, in_mempool: inMempool, confirmed: confirmed, amount: money.toSafe(confirmedReceived), observedAmount: money.toSafe(confirmedReceived + poolReceived), pendingAmount: money.toSafe(poolReceived), confirmations: confirmations, required: money.toSafe(requiredAmount), txHash: receipts[0]?.txHash || null, receipts }; } catch (error) { // Axios/Digest errors retain request configuration, including Authorization. Never // inspect the whole object or its message in logs. An unknown read must not become // a successful zero-payment observation for the late receipt scanner. this.isHealthy = false; this.lastHealthFailureAt = Date.now(); console.error('❌ Failed to check payment status:', error?.code || 'wallet_unavailable'); throw error instanceof AppError ? error : new ExternalServiceError('Wallet receipt inspection failed.', { safeMessage: 'Payment status is unavailable.', cause: error }); } } startPaymentMonitoring(address, callback, interval = 2000) { if (this.paymentWatchers.has(address)) { return; } const watcher = setInterval(async () => { try { const status = await this.checkPaymentStatus(address); if (status.in_mempool || status.confirmed) { callback(status); if (status.confirmed && status.complete) { this.stopPaymentMonitoring(address); } } } catch (_) { // Keep polling; the wallet read was unknown, not a negative payment observation. } }, 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) + '...'); } } } /** * Send a batch payout to multiple destinations in a single transfer_split call. * Uses fewer outputs and pays less in fees than individual transfers. * @param {Array<{amount: number|bigint, address: string}>} destinations * @returns {Object} - { tx_hash_list, tx_key_list, fee_list, totalFee } */ async processBatchPayout(destinations) { // Everything up to the transfer_split call below runs before anything can be broadcast. // Those failures are tagged preBroadcast so the caller can safely return the batch to // 'pending' and retry it. Untagged failures go to 'needs_review', which the retry service // never revisits. try { this._assertTransferAllowed(); if (!destinations || destinations.length === 0) { throw new AppError('No destinations provided for batch payout', { safeMessage: 'Batch payout requires at least one destination.' }); } await this.ensureNetworkIdentity(); for (const dest of destinations) { if (!dest.address || typeof dest.address !== 'string') { throw new AppError('Invalid address in batch payout', { safeMessage: 'One or more payout addresses are invalid.' }); } const validation = await this.validateAddress(dest.address); if (!validation.valid) { throw new AppError(`Invalid payout address in batch: ${dest.address.substring(0, 10)}...`, { safeMessage: 'One or more payout addresses failed validation.' }); } } } catch (err) { if (err && typeof err === 'object') err.preBroadcast = true; throw err; } const transferParams = { destinations: destinations.map(d => ({ amount: this.normalizeAtomicAmount(d.amount), address: d.address })), account_index: this.accountIndex, priority: 1, get_tx_key: true }; if (this.debugManager?.CONSOLE_LOGGING) { console.log(`🚀 Initiating batch payout (${destinations.length} destinations)`, { totalAmount: money.sum(destinations.map(d => d.amount)).toString(), destinations: destinations.map(d => d.address.substring(0, 10) + '...') }); } // Address validation may take several RPC round trips. Recheck at the last possible // point before transfer_split can broadcast. try { this._assertTransferAllowed(); } catch (err) { if (err && typeof err === 'object') err.preBroadcast = true; throw err; } const response = await this.rpcCall('transfer_split', transferParams); const hashes = Array.isArray(response.result?.tx_hash_list) ? response.result.tx_hash_list.map(hash => String(hash || '').trim()) : []; if (hashes.length === 0 || hashes.some(hash => !TX_HASH_PATTERN.test(hash))) { // The RPC call may already have broadcast. Never manufacture success without // durable chain evidence; callers move the claimed rows to manual review. throw new ExternalServiceError('Wallet returned no valid transaction-hash evidence', { safeMessage: 'The payout transfer requires manual wallet reconciliation.' }); } const result = { success: true, tx_hash_list: hashes, tx_key_list: response.result.tx_key_list || [], fee_list: response.result.fee_list || [], totalFee: money.toSafe(money.sum(response.result.fee_list || [])) }; if (this.debugManager?.CONSOLE_LOGGING) { console.log(`💸 Batch payout sent (${result.tx_hash_list.length} tx)`, { txHashes: result.tx_hash_list, totalFee: result.totalFee, destinations: destinations.length }); } return result; } 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); } let exact; try { exact = money.toBig(value); } catch (_) { throw new AppError('Payout amount must be a positive number', { safeMessage: 'Invalid payout amount supplied.' }); } if (exact <= 0n) { throw new AppError('Payout amount rounded below minimum atomic unit', { safeMessage: 'Invalid payout amount supplied.' }); } return money.toSafe(exact); } /** * Get wallet balance for admin monitoring * @returns {Object} - { balance: bigint, unlocked_balance: bigint } in atomic units */ async getBalance() { try { await this.ensureNetworkIdentity(); 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 }; } } /** * Get the wallet's own primary address (account_index 0, address_index 0). * Used by output-splitting script and internal operations. * @returns {Object} - { address: string, address_index: number } */ async getOwnAddress() { await this.ensureNetworkIdentity(); const response = await this.rpcCall('get_address', { account_index: this.accountIndex, address_index: [0] }); return { address: response.result.address, address_index: 0 }; } /** * 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 { await this.ensureNetworkIdentity(); 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 { this._assertTransferAllowed(); if (!address || typeof address !== 'string') { throw new AppError('Payout address was not provided or invalid', { safeMessage: 'Invalid payout address supplied.' }); } await this.ensureNetworkIdentity(); 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 }); } this._assertTransferAllowed(); // transfer_split spreads a payout across multiple outputs when needed, so it succeeds // in wallet states where a plain transfer would fail on output availability. const response = await this.rpcCall('transfer_split', transferParams); // transfer_split returns arrays: tx_hash_list, tx_key_list, fee_list const txHashes = Array.isArray(response.result?.tx_hash_list) ? response.result.tx_hash_list.map(hash => String(hash || '').trim()) : []; if (txHashes.length !== 1 || !TX_HASH_PATTERN.test(txHashes[0])) { // One payout row must map to exactly one valid hash. transfer_split may have // broadcast already, so this is deliberately an ambiguous failure and must not // be automatically retried by callers. throw new ExternalServiceError('Wallet returned ambiguous transaction-hash evidence', { safeMessage: 'The payout transfer requires manual wallet reconciliation.' }); } const txHash = txHashes[0]; const txKey = response.result.tx_key_list?.length === 1 ? response.result.tx_key_list[0] : null; const fee = money.toSafe(money.sum(response.result.fee_list || [])); if (this.debugManager?.CONSOLE_LOGGING) { console.log('💸 Payout sent successfully', { to: address.substring(0, 10) + '...', amount: normalizedAmount, txHash, fee, txCount: response.result.tx_hash_list?.length || 1 }); } return { success: true, txHash, txKey, 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 { await this.ensureNetworkIdentity(); if (!txHash || typeof txHash !== 'string') { return { exists: false, confirmed: false, in_mempool: false }; } 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) { // A "not found" RPC error is proof of absence; any other error leaves existence // unknown, so it propagates rather than reporting a missing transaction. if (error.message?.includes('not found') || error.details?.message?.includes('not found')) { return { exists: false, confirmed: false, in_mempool: false }; } throw error; } } cleanupUserPayments(userId) { const addressesToRemove = []; for (const [address, userInfo] of this.addressToUser.entries()) { if (userInfo.userId === userId || userInfo.socketId === userId) { addressesToRemove.push(address); } } for (const address of addressesToRemove) { this.stopPaymentMonitoring(address); this.addressToUser.delete(address); this.addressToSocket.delete(address); } } getHealthStatus() { return { healthy: this.isHealthy && (!this.identityRequired || this.isIdentityFresh()) && (!this.authRequired || this.isAuthenticationFresh()), endpoint: endpointLabel(this.walletEndpoint), activeMonitors: this.paymentWatchers.size, pendingPayments: this.addressToUser.size, identity: this.getIdentityStatus(), authentication: { required: this.authRequired, verified: this.isAuthenticationFresh(), reason: this.authentication.reason || null, checkedAt: Number(this.authentication.checkedAt || 0) } }; } } module.exports = WalletRPCService; module.exports.cryptoTypeFromAddress = cryptoTypeFromAddress; module.exports.endpointLabel = endpointLabel;