Merge pull request #6 from Such-Software/fix/wow-payment-evidence-unknown-20261021

Keep failed receipt reads unknown and retain reviewed late-payment decisions.
This commit is contained in:
John Winter Murphy
2026-10-21 18:11:16 -04:00
committed by GitHub
8 changed files with 600 additions and 77 deletions
+8 -3
View File
@@ -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
};
+34 -10
View File
@@ -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',
+88 -41
View File
@@ -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);
+20 -2
View File
@@ -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 };
+133
View File
@@ -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();
});
});
+133
View File
@@ -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
);
+35
View File
@@ -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);
+149 -21
View File
@@ -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', {