diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index af0f8d9..802449d 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -597,7 +597,10 @@ class GameModeManager { * The payment is left expired and a durable manual-review item is upserted. This makes late * mining/refund cases visible while keeping stale or unbound entries out of the game queue. */ - async reconcileLatePaymentForReview(paymentId, receipts = []) { + async reconcileLatePaymentForReview(paymentId, receipts) { + if (!Array.isArray(receipts)) { + throw new ValidationError('Payment receipt evidence is unavailable'); + } return this.db.withTransaction(async (client) => { const locked = await client.query(` SELECT id, status, expected_amount, provider_id, address_index @@ -631,7 +634,7 @@ class GameModeManager { } const coverageComplete = observed >= money.toBig(payment.expected_amount); - await client.query(` + const review = await client.query(` INSERT INTO payment_late_reviews ( payment_id, provider_id, observed_amount, expected_amount, receipt_count, metadata @@ -647,13 +650,15 @@ class GameModeManager { metadata = EXCLUDED.metadata, last_observed_at = NOW(), resolved_at = NULL + WHERE payment_late_reviews.status = 'needs_review' + RETURNING status `, [paymentId, payment.provider_id || 'native-monero', observed.toString(), String(payment.expected_amount), evidence.receiptCount, JSON.stringify({ coverageComplete, entitlementGranted: false })]); return { checked: true, - needsReview: true, + needsReview: review.rows[0]?.status === 'needs_review', observedAmount: observed.toString(), coverageComplete }; diff --git a/src/payments/providers/btcpayProvider.js b/src/payments/providers/btcpayProvider.js index 7cdda48..026d4aa 100644 --- a/src/payments/providers/btcpayProvider.js +++ b/src/payments/providers/btcpayProvider.js @@ -3,15 +3,17 @@ const ChainProfile = require('../../chain/chainProfile'); const Atomic = require('../../money/atomic'); // Convert a decimal crypto string (e.g. "1.5") to an integer atomic amount for `decimals` places. -// Truncates any digits beyond `decimals` (the gateway already quantizes to chain precision). +// Extra fractional places are accepted only when they are zero at chain precision. function decimalToAtomic(dec, decimals) { const s = String(dec == null ? '' : dec).trim(); - if (!s || s === '-') return 0; - const neg = s[0] === '-'; - const [ip, fpRaw = ''] = s.replace('-', '').split('.'); + if (!/^(0|[1-9]\d*)(?:\.\d+)?$/.test(s)) { + throw new Error('Invalid invoice amount'); + } + const [ip, fpRaw = ''] = s.split('.'); + if (/[^0]/.test(fpRaw.slice(decimals))) throw new Error('Invalid invoice amount precision'); const fp = (fpRaw + '0'.repeat(decimals)).slice(0, decimals); const atomic = BigInt(ip || '0') * (10n ** BigInt(decimals)) + BigInt(fp || '0'); - return Atomic.toSafe(neg ? -atomic : atomic); + return Atomic.toSafe(atomic); } /** @@ -121,17 +123,39 @@ class BTCPayProvider extends PaymentProvider { async getWalletStatus(invoiceRef) { const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef; const inv = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}`); + if (!inv || !['Settled', 'Complete', 'Processing', 'Expired', 'Invalid', 'New'].includes(inv.status)) { + throw new Error('Invoice status evidence is unavailable'); + } const mapped = BTCPayProvider._mapStatus(inv.status); let amount = 0, required = 0; if (mapped.status === 'processing' || mapped.status === 'paid') { + let methods; try { - const methods = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}/payment-methods`); - const list = Array.isArray(methods) ? methods : []; - const m = list[0] || {}; - const dec = ChainProfile.decimalsFor(m.cryptoCode || m.paymentMethod || ''); + methods = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}/payment-methods`); + } catch (_) { + throw new Error('Invoice payment-method read failed'); + } + if (!Array.isArray(methods) || methods.length === 0) { + throw new Error('Invoice payment-method evidence is unavailable'); + } + const m = methods[0]; + if (!m || (m.totalPaid == null && m.paymentMethodPaid == null) || m.amount == null) { + throw new Error('Invoice payment-method amounts are unavailable'); + } + try { + const chain = String(m.cryptoCode || m.paymentMethod || '').split('-')[0].toUpperCase(); + if (!ChainProfile.isSupported(chain) || !this.supportsChain(chain)) { + throw new Error('Unsupported invoice payment method'); + } + const dec = ChainProfile.decimalsFor(chain); amount = decimalToAtomic(m.totalPaid != null ? m.totalPaid : m.paymentMethodPaid, dec); required = decimalToAtomic(m.amount, dec); - } catch (_) { /* amount stays 0; a Settled invoice still confirms via `complete` */ } + } catch (_) { + throw new Error('Invoice payment-method amounts are invalid'); + } + if (Atomic.toBig(required) <= 0n || (mapped.status === 'paid' && Atomic.toBig(amount) <= 0n)) { + throw new Error('Invoice payment-method amounts are incomplete'); + } } return { in_mempool: mapped.status === 'processing', diff --git a/src/payments/walletRPCService.js b/src/payments/walletRPCService.js index 692bcf7..46d118e 100644 --- a/src/payments/walletRPCService.js +++ b/src/payments/walletRPCService.js @@ -8,6 +8,20 @@ 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; @@ -398,17 +412,14 @@ class WalletRPCService { async checkPaymentStatus(address) { try { - await this.ensureNetworkIdentity(); const userInfo = this.addressToUser.get(address); if (!userInfo) { - return { - complete: false, - in_mempool: false, - confirmed: false, - amount: 0, - confirmations: 0 - }; + 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 @@ -422,11 +433,15 @@ class WalletRPCService { 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; @@ -463,6 +478,12 @@ class WalletRPCService { 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; @@ -497,22 +518,32 @@ class WalletRPCService { // 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 (!TX_HASH_PATTERN.test(txHash) - || !belongsToRequestedSubaddress(tx) + 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) - || tx?.double_spend_seen !== false + ) continue; + incomingTx.set(txHash, { height, amount: transactionAmount }); + if (tx.double_spend_seen !== false || !hasSafeUnlockTime(tx) - || !Number.isSafeInteger(height) || height <= 0 - || !Number.isSafeInteger(reportedConfirmations) + || height <= 0 || reportedConfirmations < this.minConfirmations - || transactionAmount === null || transactionAmount <= 0n) { + ) { continue; } confirmedTx.set(txHash, { @@ -522,28 +553,39 @@ class WalletRPCService { }); } + 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); - if (!txState - || addressIndex !== userInfo.addressIndex - || (accountIndex !== null && accountIndex !== userInfo.accountIndex) - || !outputId - || !Number.isSafeInteger(blockHeight) || blockHeight <= 0 - || blockHeight !== txState.height - || output?.frozen === true) { + 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; } - const amount = money.toBig(output.amount); - if (amount <= 0n || amount > txState.remainingAmount) continue; - const receiptKey = `${txHash}:${outputId}`; + if (amount > txState.remainingAmount) throw incompleteReceiptEvidence(); // RPC responses may repeat the same output; replaying identical evidence must // never increase confirmed coverage. - if (confirmedReceipts.has(receiptKey)) continue; confirmedReceipts.set(receiptKey, { evidenceType: 'chain_output', evidenceId: receiptKey, @@ -557,6 +599,9 @@ class WalletRPCService { confirmedReceived += amount; txState.remainingAmount -= amount; } + if (Array.from(confirmedTx.keys()).some(txHash => !observedConfirmedTx.has(txHash))) { + throw incompleteReceiptEvidence(); + } confirmed = confirmedReceipts.size > 0; if (confirmed) { @@ -600,15 +645,15 @@ class WalletRPCService { }; } catch (error) { // Axios/Digest errors retain request configuration, including Authorization. Never - // inspect the whole object in logs; rpcCall already records a credential-free label. - console.error('❌ Failed to check payment status:', error?.message || 'wallet RPC failure'); - return { - complete: false, - in_mempool: false, - confirmed: false, - amount: 0, - confirmations: 0 - }; + // 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 + }); } } @@ -618,14 +663,16 @@ class WalletRPCService { } const watcher = setInterval(async () => { - const status = await this.checkPaymentStatus(address); - - if (status.in_mempool || status.confirmed) { - callback(status); - - if (status.confirmed && status.complete) { - this.stopPaymentMonitoring(address); + 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); diff --git a/src/services/latePaymentReconciler.js b/src/services/latePaymentReconciler.js index 947b522..d7e9562 100644 --- a/src/services/latePaymentReconciler.js +++ b/src/services/latePaymentReconciler.js @@ -1,4 +1,9 @@ const money = require('../money/atomic'); +const SAFE_EVIDENCE_CODES = new Set([ + 'WALLET_PAYMENT_ADDRESS_UNAVAILABLE', 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE', + 'WALLET_IDENTITY_MISMATCH', 'WALLET_RPC_AUTH_NOT_ENFORCED', + 'WALLET_RPC_AUTH_UNVERIFIED', 'EXTERNAL_SERVICE_ERROR', 'VALIDATION_ERROR' +]); function boundedInteger(value, fallback, min, max) { const parsed = Number.parseInt(String(value ?? ''), 10); @@ -97,9 +102,20 @@ class LatePaymentReconciler { for (const payment of candidates.rows) { try { const status = await this._status(payment); + if (!status || !Array.isArray(status.receipts) + || typeof status.confirmed !== 'boolean' + || typeof status.complete !== 'boolean' + || (status.in_mempool === true && status.receipts.length === 0) + || (status.confirmed === true && status.receipts.length === 0) + || (status.complete === true && status.confirmed !== true) + || (status.receipts.length > 0 && status.confirmed !== true) + || ((payment.provider_id || 'native-monero') !== 'native-monero' + && status.receipts.length === 0)) { + throw new Error('Payment receipt evidence is unavailable'); + } const result = await this.gameModeManager.reconcileLatePaymentForReview( payment.id, - Array.isArray(status?.receipts) ? status.receipts : [] + status.receipts ); if (result?.checked) { checked += 1; @@ -111,7 +127,9 @@ class LatePaymentReconciler { } } catch (error) { errors += 1; - this.logger.error?.(`[LatePayment] Reconciliation failed for payment ${payment.id}: ${error.message}`); + const reason = SAFE_EVIDENCE_CODES.has(error?.code) + ? error.code : 'payment_evidence_unavailable'; + this.logger.error?.(`[LatePayment] Reconciliation failed for payment ${payment.id}: ${reason}`); } } return { skipped: false, checked, reviews, errors }; diff --git a/test/latePaymentReconciliation.test.js b/test/latePaymentReconciliation.test.js index 66877b0..c944695 100644 --- a/test/latePaymentReconciliation.test.js +++ b/test/latePaymentReconciliation.test.js @@ -52,6 +52,129 @@ describe('late incoming payment reconciliation', () => { expect(walletService.stopPaymentMonitoring).toHaveBeenCalledWith('late-address'); }); + test.each([ + ['missing status', undefined], + ['missing receipts', { complete: false }], + ['non-array receipts', { receipts: null }], + ['malformed receipts', { receipts: {} }], + ['nonfinal positive without receipts', { in_mempool: true, confirmed: false, complete: false, receipts: [] }], + ['confirmed without receipts', { confirmed: true, complete: true, receipts: [] }], + ['complete without confirmation', { confirmed: false, complete: true, receipts: [] }], + ['receipts without confirmation', { confirmed: false, complete: false, receipts: [{}] }] + ])('does not mark the invoice scanned on %s', async (_case, status) => { + const row = { + id: 9, user_id: 3, status: 'expired', subaddress: 'late-address', + address_index: 11, expected_amount: '100', provider_id: 'native-monero' + }; + const walletService = { + accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(), + checkPaymentStatus: jest.fn().mockResolvedValue(status), + stopPaymentMonitoring: jest.fn() + }; + const gameModeManager = { + reconcileLatePaymentForReview: jest.fn(), paymentProviders: { get: jest.fn() } + }; + const service = new LatePaymentReconciler({ + db: { query: jest.fn().mockResolvedValue({ rows: [row] }) }, + gameModeManager, walletService, + logger: { warn: jest.fn(), error: jest.fn() } + }); + + expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 }); + expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled(); + expect(walletService.stopPaymentMonitoring).not.toHaveBeenCalled(); + expect(walletService.addressToUser.has(row.subaddress)).toBe(false); + }); + + test('gateway status alone cannot establish an empty historical receipt scan', async () => { + const provider = { getWalletStatus: jest.fn().mockResolvedValue({ + _terminal: true, confirmed: false, complete: false, receipts: [] + }) }; + const gameModeManager = { + paymentProviders: { get: jest.fn(() => provider) }, + reconcileLatePaymentForReview: jest.fn() + }; + const service = new LatePaymentReconciler({ + db: { query: jest.fn().mockResolvedValue({ rows: [{ + id: 13, user_id: 3, subaddress: 'gateway-address', + address_index: 0, expected_amount: '100', provider_id: 'btcpay', + provider_invoice_id: 'gateway-invoice' + }] }) }, + gameModeManager, + logger: { warn: jest.fn(), error: jest.fn() } + }); + + expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 }); + expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled(); + }); + + test('does not log upstream response text from a failed gateway receipt read', async () => { + const privateMarker = 'untrusted-provider-response-canary'; + const provider = { getWalletStatus: jest.fn().mockRejectedValue(new Error(privateMarker)) }; + const logger = { warn: jest.fn(), error: jest.fn() }; + const service = new LatePaymentReconciler({ + db: { query: jest.fn().mockResolvedValue({ rows: [{ + id: 14, user_id: 3, subaddress: 'gateway-address', + address_index: 0, expected_amount: '100', provider_id: 'btcpay', + provider_invoice_id: 'gateway-invoice' + }] }) }, + gameModeManager: { + paymentProviders: { get: jest.fn(() => provider) }, + reconcileLatePaymentForReview: jest.fn() + }, + logger + }); + + expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 }); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0].includes(privateMarker)).toBe(false); + }); + + test('does not advance the marker when the wallet read rejects', async () => { + const walletService = { + accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(), + checkPaymentStatus: jest.fn().mockRejectedValue(new Error('wallet unavailable')), + stopPaymentMonitoring: jest.fn() + }; + const gameModeManager = { reconcileLatePaymentForReview: jest.fn() }; + const service = new LatePaymentReconciler({ + db: { query: jest.fn().mockResolvedValue({ rows: [{ + id: 10, user_id: 3, subaddress: 'late-address', + address_index: 11, expected_amount: '100', provider_id: 'native-monero' + }] }) }, + gameModeManager, walletService, + logger: { warn: jest.fn(), error: jest.fn() } + }); + + expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 }); + expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled(); + expect(walletService.stopPaymentMonitoring).not.toHaveBeenCalled(); + }); + + test('a successful empty receipt list advances the scan without a review', async () => { + const walletService = { + accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(), + checkPaymentStatus: jest.fn().mockResolvedValue({ + complete: false, confirmed: false, receipts: [] + }), + stopPaymentMonitoring: jest.fn() + }; + const gameModeManager = { + reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: false }) + }; + const service = new LatePaymentReconciler({ + db: { query: jest.fn().mockResolvedValue({ rows: [{ + id: 11, user_id: 3, subaddress: 'late-address', + address_index: 11, expected_amount: '100', provider_id: 'native-monero' + }] }) }, + gameModeManager, walletService, + logger: { warn: jest.fn(), error: jest.fn() } + }); + + expect(await service.runOnce()).toEqual({ skipped: false, checked: 1, reviews: 0, errors: 0 }); + expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(11, []); + }); + test('durable late receipt stays expired and creates review without any entitlement write', async () => { const calls = []; const client = { @@ -66,6 +189,9 @@ describe('late incoming payment reconciliation', () => { if (/COALESCE\(SUM\(amount\)/i.test(sql)) { return { rows: [{ total: '100', primary_tx_hash: 'c'.repeat(64), receipt_count: '1' }] }; } + if (/INSERT INTO payment_late_reviews/i.test(sql)) { + return { rows: [{ status: 'needs_review' }], rowCount: 1 }; + } return { rows: [], rowCount: 1 }; }) }; @@ -91,4 +217,11 @@ describe('late incoming payment reconciliation', () => { expect(calls.some(call => /SET status = CASE WHEN status = 'pending' THEN 'expired'/i.test(call.sql))).toBe(true); expect(calls.some(call => /UPDATE users|payment_entitlement_grants|credit_transactions/i.test(call.sql))).toBe(false); }); + + test('the manager refuses missing evidence before starting a transaction', async () => { + const manager = Object.create(GameModeManager.prototype); + manager.db = { withTransaction: jest.fn() }; + await expect(manager.reconcileLatePaymentForReview(12)).rejects.toThrow(); + expect(manager.db.withTransaction).not.toHaveBeenCalled(); + }); }); diff --git a/test/latePaymentReviewPostgres.test.js b/test/latePaymentReviewPostgres.test.js new file mode 100644 index 0000000..6c2451f --- /dev/null +++ b/test/latePaymentReviewPostgres.test.js @@ -0,0 +1,133 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { createRequire } = require('module'); +const { spawnSync } = require('child_process'); +const GameModeManager = require('../src/game/gameModeManager'); + +const { Client } = createRequire(path.join(__dirname, '../src/package.json'))('pg'); + +function commandAvailable(name) { + return spawnSync(name, ['--version'], { stdio: 'ignore' }).status === 0; +} + +const postgresAvailable = process.getuid?.() !== 0 + && commandAvailable('initdb') && commandAvailable('pg_ctl'); + +function run(name, args, environment) { + const result = spawnSync(name, args, { encoding: 'utf8', env: environment, timeout: 15000 }); + if (result.status !== 0) { + throw new Error(`${name} failed for disposable local PostgreSQL fixture`); + } +} + +const sql = ` + CREATE TABLE payments ( + id integer PRIMARY KEY, status text NOT NULL, expected_amount bigint NOT NULL, + provider_id text NOT NULL, address_index integer NOT NULL, + late_receipt_checked_at timestamptz, received_amount bigint + ); + CREATE TABLE payment_receipts ( + payment_id integer NOT NULL, provider_id text NOT NULL, + evidence_type text NOT NULL, evidence_id text NOT NULL, + tx_hash text, output_id text, address_index integer, amount bigint NOT NULL, + confirmed boolean NOT NULL, UNIQUE (provider_id, evidence_id) + ); + CREATE TABLE payment_late_reviews ( + payment_id integer UNIQUE NOT NULL, provider_id text NOT NULL, + status text NOT NULL DEFAULT 'needs_review' + CHECK (status IN ('needs_review', 'resolved', 'ignored')), + observed_amount bigint NOT NULL, + expected_amount bigint NOT NULL, receipt_count integer NOT NULL, + metadata jsonb NOT NULL, last_observed_at timestamptz DEFAULT now(), + resolved_at timestamptz + ); +`; + +(postgresAvailable ? test : test.skip)( + 'real PostgreSQL keeps decided late reviews closed while repeated evidence updates open reviews', + async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wow-late-review-')); + const data = path.join(root, 'data'); + const socket = path.join(root, 'socket'); + fs.mkdirSync(socket, { mode: 0o700 }); + const environment = { PATH: process.env.PATH, HOME: root, LANG: 'C', LC_ALL: 'C' }; + let startAttempted = false; + let client; + try { + run('initdb', ['-D', data, '-A', 'trust', '-U', 'wow_fixture', '-N'], environment); + startAttempted = true; + run('pg_ctl', ['-D', data, '-l', path.join(root, 'postgres.log'), + '-o', `-c listen_addresses= -c unix_socket_directories=${socket} -p 5432`, + '-w', 'start'], environment); + client = new Client({ host: socket, port: 5432, user: 'wow_fixture', + database: 'postgres', connectionTimeoutMillis: 3000 }); + await client.connect(); + await client.query(sql); + const manager = Object.create(GameModeManager.prototype); + manager.db = { withTransaction: async callback => { + await client.query('BEGIN'); + try { + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + } }; + + for (const [id, status] of [[1, 'resolved'], [2, 'ignored'], [3, 'needs_review']]) { + await client.query(`INSERT INTO payments + (id, status, expected_amount, provider_id, address_index) + VALUES ($1, 'expired', 100, 'native-monero', $1)`, [id]); + await client.query(`INSERT INTO payment_late_reviews + (payment_id, provider_id, status, observed_amount, expected_amount, + receipt_count, metadata, resolved_at) + VALUES ($1, 'native-monero', $2, $3, 100, 1, + '{"operatorDecision":"retained"}'::jsonb, + CASE WHEN $2 = 'needs_review' THEN NULL ELSE now() END)`, + [id, status, status === 'needs_review' ? 50 : 100]); + const txHash = String(id).repeat(64); + const outputId = String(id + 3).repeat(64); + const result = await manager.reconcileLatePaymentForReview(id, [{ + evidenceType: 'chain_output', providerId: 'native-monero', + txHash, outputId, evidenceId: `${txHash}:${outputId}`, + addressIndex: id, amount: '100', confirmed: true + }]); + const observed = (await client.query(`SELECT r.status, r.metadata, r.resolved_at, + r.observed_amount, p.late_receipt_checked_at, + COUNT(pr.payment_id)::integer AS receipts + FROM payment_late_reviews r + JOIN payments p ON p.id = r.payment_id + LEFT JOIN payment_receipts pr ON pr.payment_id = p.id + WHERE p.id = $1 + GROUP BY r.status, r.metadata, r.resolved_at, r.observed_amount, + p.late_receipt_checked_at`, [id])).rows[0]; + expect(result.checked).toBe(true); + expect(result.needsReview).toBe(status === 'needs_review'); + expect(observed.status).toBe(status); + expect(observed.receipts).toBeGreaterThan(0); + expect(observed.observed_amount).toBe('100'); + expect(observed.late_receipt_checked_at).not.toBeNull(); + if (status !== 'needs_review') { + expect(observed.resolved_at).not.toBeNull(); + expect(observed.metadata.operatorDecision).toBe('retained'); + } + } + } finally { + if (client) await client.end().catch(() => {}); + if (startAttempted) { + const stopped = spawnSync('pg_ctl', ['-D', data, '-m', 'immediate', '-w', 'stop'], + { env: environment, stdio: 'ignore', timeout: 15000 }); + if (stopped.status !== 0) { + throw new Error(`Disposable PostgreSQL fixture stop failed; retained ${root} for inspection`); + } + } + fs.rmSync(root, { recursive: true, force: true }); + } + }, + 30000 +); diff --git a/test/paymentProviders_ultra.test.js b/test/paymentProviders_ultra.test.js index 70c26a0..d4901b9 100644 --- a/test/paymentProviders_ultra.test.js +++ b/test/paymentProviders_ultra.test.js @@ -96,6 +96,13 @@ describe('BTCPayProvider (Greenfield)', () => { expect(st.amount).toBe(150000000000); // 1.5 * 1e11 }); + test('accepts harmless gateway trailing zeros beyond chain precision', async () => { + const st = await mk('Settled', [{ + cryptoCode: 'WOW', totalPaid: '1.500000000000', amount: '1.500000000000' + }], ['WOW']).getWalletStatus('inv1'); + expect(st.amount).toBe(150000000000); + }); + test('gateway atomic amounts above Number.MAX_SAFE_INTEGER remain exact strings', async () => { const st = await mk('Settled', [{ cryptoCode: 'XMR', @@ -114,6 +121,34 @@ describe('BTCPayProvider (Greenfield)', () => { expect(st.amount).toBe(500000000000); }); + test.each([null, {}, [], [{ cryptoCode: 'WOW', amount: '1' }], + [{ cryptoCode: 'WOW', totalPaid: '0', amount: '1' }], + [{ cryptoCode: 'WOW', totalPaid: '1.2.3', amount: '1' }], + [{ cryptoCode: 'WOW', totalPaid: '1-', amount: '1' }], + [{ cryptoCode: 'WOW', totalPaid: '1.000000000001', amount: '1' }], + [{ cryptoCode: 'UNKNOWN', totalPaid: '1', amount: '1' }], + [{ totalPaid: '1', amount: '1' }]])( + 'refuses settled invoices with incomplete payment-method evidence', async (methods) => { + await expect(mk('Settled', methods, ['WOW']).getWalletStatus('inv1')).rejects.toThrow(); + } + ); + + test('refuses a failed payment-method read for a settled invoice', async () => { + const provider = new BTCPayProvider({ + baseUrl: 'x', storeId: 's', apiKey: 'k', chains: ['WOW'], + fetchImpl: recordingFetch([ + { method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status: 'Settled' } }, + { method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, ok: false, status: 503, + body: { message: 'payment evidence unavailable' } } + ]) + }); + await expect(provider.getWalletStatus('inv1')).rejects.toThrow(); + }); + + test('refuses an unrecognized gateway status instead of reporting an empty scan', async () => { + await expect(mk('Unrecognized', [], ['WOW']).getWalletStatus('inv1')).rejects.toThrow(); + }); + test('New -> pending, no amount', async () => { const st = await mk('New', [], ['XMR']).getWalletStatus('inv1'); expect(st.confirmed).toBe(false); diff --git a/test/walletRPCService.test.js b/test/walletRPCService.test.js index c21d8be..1606459 100644 --- a/test/walletRPCService.test.js +++ b/test/walletRPCService.test.js @@ -398,6 +398,22 @@ describe('WalletRPCService Payment Monitoring', () => { svc.stopPaymentMonitoring(address); }); + test('a failed poll does not call the payer or stop the monitor', async () => { + const callback = jest.fn(); + svc.checkPaymentStatus = jest.fn() + .mockRejectedValueOnce(new Error('wallet unavailable')) + .mockResolvedValueOnce({ confirmed: true, complete: true }); + svc.startPaymentMonitoring('wow1retry', callback, 100); + + await jest.advanceTimersByTimeAsync(100); + expect(callback).not.toHaveBeenCalled(); + expect(svc.paymentWatchers.has('wow1retry')).toBe(true); + + await jest.advanceTimersByTimeAsync(100); + expect(callback).toHaveBeenCalledTimes(1); + expect(svc.paymentWatchers.has('wow1retry')).toBe(false); + }); + test('cleanupUserPayments removes all monitors for user', () => { const userId = 'user123'; const addr1 = 'wow1addr1'; @@ -429,14 +445,58 @@ describe('WalletRPCService.checkPaymentStatus', () => { svc = new WalletRPCService(debugManagerMock); }); - test('returns default status for unknown address', async () => { - const status = await svc.checkPaymentStatus('unknown_address'); - - expect(status.complete).toBe(false); - expect(status.in_mempool).toBe(false); - expect(status.confirmed).toBe(false); - expect(status.amount).toBe(0); - expect(status.confirmations).toBe(0); + test('does not classify an unregistered address as a successful empty wallet read', async () => { + await expect(svc.checkPaymentStatus('unknown_address')).rejects.toMatchObject({ + code: 'WALLET_PAYMENT_ADDRESS_UNAVAILABLE' + }); + }); + + test.each([ + ['explicit empty lists', { in: [], pool: [] }, { transfers: [] }], + ['omitted empty lists', {}, {}] + ])('distinguishes %s from unknown evidence', async (_case, transfers, outputs) => { + svc.addressToUser.set('wow1empty', { + userId: 'user1', socketId: 'sock1', amount: 100, + addressIndex: 5, accountIndex: 0 + }); + svc.rpcCall = jest.fn() + .mockResolvedValueOnce({ result: transfers }) + .mockResolvedValueOnce({ result: outputs }); + + const status = await svc.checkPaymentStatus('wow1empty'); + expect(status).toEqual(expect.objectContaining({ + complete: false, confirmed: false, amount: 0, receipts: [] + })); + }); + + test.each([ + ['first wallet RPC failure', () => jest.fn().mockRejectedValue(new Error('transport failed'))], + ['second wallet RPC failure', () => jest.fn() + .mockResolvedValueOnce({ result: { in: [], pool: [] } }) + .mockRejectedValueOnce(new Error('auth failed'))], + ['missing transaction result', () => jest.fn() + .mockResolvedValueOnce({ result: null }) + .mockResolvedValueOnce({ result: { transfers: [] } })], + ['non-array output list', () => jest.fn() + .mockResolvedValueOnce({ result: { in: [], pool: [] } }) + .mockResolvedValueOnce({ result: { transfers: null } })], + ['malformed transfer list', () => jest.fn() + .mockResolvedValueOnce({ result: { in: {}, pool: [] } }) + .mockResolvedValueOnce({ result: { transfers: [] } })] + ])('rejects %s instead of reporting zero receipts', async (_case, rpc) => { + svc.addressToUser.set('wow1unknown', { + userId: 'user1', socketId: 'sock1', amount: 100, + addressIndex: 5, accountIndex: 0 + }); + svc.rpcCall = rpc(); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(svc.checkPaymentStatus('wow1unknown')).rejects.toMatchObject({ + statusCode: expect.any(Number), code: expect.any(String) + }); + } finally { + log.mockRestore(); + } }); test('checks mempool and confirmed transfers', async () => { @@ -524,7 +584,7 @@ describe('WalletRPCService.checkPaymentStatus', () => { addressIndex: 8, accountIndex: 0, detected: false, confirmed: false }); svc.rpcCall = jest.fn() - .mockResolvedValueOnce({ result: { pool: [], in: [ + .mockResolvedValueOnce({ result: { in: [ confirmedTransfer({ txid: 'b'.repeat(64), amount: 400, height: 301, confirmations: 3, minor: 8 }), confirmedTransfer({ txid: 'a'.repeat(64), amount: 600, height: 302, @@ -577,7 +637,7 @@ describe('WalletRPCService.checkPaymentStatus', () => { expect(status.receipts.map(receipt => receipt.outputId)).toEqual([firstPubkey, secondPubkey]); }); - test('fails closed when a confirmed transfer has no stable output identifier', async () => { + test('reports unknown evidence when a confirmed transfer lacks its output identifier', async () => { svc.addressToUser.set('wow1missingoutputid', { userId: 'user1', socketId: 'sock1', amount: 100, addressIndex: 14, accountIndex: 0, detected: false, confirmed: false @@ -590,12 +650,63 @@ describe('WalletRPCService.checkPaymentStatus', () => { txHash: '8'.repeat(64), amount: 100, blockHeight: 500, minor: 14 })] } }); - const status = await svc.checkPaymentStatus('wow1missingoutputid'); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(svc.checkPaymentStatus('wow1missingoutputid')).rejects.toMatchObject({ + code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE' + }); + } finally { + log.mockRestore(); + } + }); - expect(status.complete).toBe(false); - expect(status.confirmed).toBe(false); - expect(status.amount).toBe(0); - expect(status.receipts).toEqual([]); + test('reports unknown evidence when the output RPC omits a safe confirmed transfer', async () => { + svc.addressToUser.set('wow1missingoutput', { + userId: 'user1', socketId: 'sock1', amount: 100, + addressIndex: 14, accountIndex: 0 + }); + svc.rpcCall = jest.fn() + .mockResolvedValueOnce({ result: { pool: [], in: [confirmedTransfer({ + txid: '8'.repeat(64), amount: 100, height: 500, confirmations: 1, minor: 14 + })] } }) + .mockResolvedValueOnce({ result: { transfers: [] } }); + + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(svc.checkPaymentStatus('wow1missingoutput')).rejects.toMatchObject({ + code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE' + }); + } finally { + log.mockRestore(); + } + }); + + test.each([ + ['mismatched block height', { amount: 100, blockHeight: 501 }, true], + ['output amount exceeding the transaction', { amount: 101, blockHeight: 500 }, true], + ['output without transaction state', { amount: 100, blockHeight: 500 }, false] + ])('rejects %s across wallet receipt views', async (_case, outputShape, includeTx) => { + svc.addressToUser.set('wow1contradiction', { + userId: 'user1', socketId: 'sock1', amount: 100, + addressIndex: 14, accountIndex: 0 + }); + svc.rpcCall = jest.fn() + .mockResolvedValueOnce({ result: includeTx ? { in: [confirmedTransfer({ + txid: '8'.repeat(64), amount: 100, height: 500, confirmations: 1, minor: 14 + })] } : {} }) + .mockResolvedValueOnce({ result: { transfers: [incomingOutput({ + txHash: '8'.repeat(64), pubkey: 'a'.repeat(64), globalIndex: 500, + minor: 14, ...outputShape + })] } }); + + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(svc.checkPaymentStatus('wow1contradiction')).rejects.toMatchObject({ + code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE' + }); + } finally { + log.mockRestore(); + } }); test('ignores double-spend-marked and explicitly timelocked incoming transfers', async () => { @@ -608,17 +719,13 @@ describe('WalletRPCService.checkPaymentStatus', () => { confirmedTransfer({ txid: '7'.repeat(64), amount: 100, height: 600, confirmations: 2, minor: 15, doubleSpend: true }), confirmedTransfer({ txid: '6'.repeat(64), amount: 100, height: 601, - confirmations: 2, minor: 15, unlockTime: 42 }), - confirmedTransfer({ txid: '5'.repeat(64), amount: 100, height: 602, - confirmations: 2, minor: 15, unlockTime: Number.MAX_SAFE_INTEGER + 1 }) + confirmations: 2, minor: 15, unlockTime: 42 }) ] } }) .mockResolvedValueOnce({ result: { transfers: [ incomingOutput({ txHash: '7'.repeat(64), pubkey: 'a'.repeat(64), globalIndex: 600, amount: 100, blockHeight: 600, minor: 15 }), incomingOutput({ txHash: '6'.repeat(64), pubkey: 'b'.repeat(64), globalIndex: 601, - amount: 100, blockHeight: 601, minor: 15 }), - incomingOutput({ txHash: '5'.repeat(64), pubkey: 'c'.repeat(64), globalIndex: 602, - amount: 100, blockHeight: 602, minor: 15 }) + amount: 100, blockHeight: 601, minor: 15 }) ] } }); const status = await svc.checkPaymentStatus('wow1unsafeoutputs'); @@ -629,6 +736,27 @@ describe('WalletRPCService.checkPaymentStatus', () => { expect(status.receipts).toEqual([]); }); + test('rejects an incoming transfer with an unsafe numeric unlock time', async () => { + svc.addressToUser.set('wow1invalidunlock', { + userId: 'user1', socketId: 'sock1', amount: 100, + addressIndex: 15, accountIndex: 0 + }); + svc.rpcCall = jest.fn() + .mockResolvedValueOnce({ result: { in: [confirmedTransfer({ + txid: '5'.repeat(64), amount: 100, height: 602, + confirmations: 2, minor: 15, unlockTime: Number.MAX_SAFE_INTEGER + 1 + })] } }) + .mockResolvedValueOnce({ result: { transfers: [] } }); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(svc.checkPaymentStatus('wow1invalidunlock')).rejects.toMatchObject({ + code: 'WALLET_RECEIPT_EVIDENCE_INCOMPLETE' + }); + } finally { + log.mockRestore(); + } + }); + test('requires the configured minimum confirmations and rejects frozen output evidence', async () => { svc = new WalletRPCService(debugManagerMock, { minConfirmations: 2 }); svc.addressToUser.set('wow1depth', {