mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
fix(payments): preserve unknown receipt evidence
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user