mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
1083 lines
46 KiB
JavaScript
1083 lines
46 KiB
JavaScript
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;
|