mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 07:38:34 +02:00
payouts: defer when Monero outputs are locked instead of failing
Spent outputs (including change) lock for ~10 blocks on Monero. When a payout batch has no unlocked balance to cover it, attempting transfer_split fails noisily and, for batches, gets parked in needs_review for an operator. Add a pre-flight unlocked-balance check that reverts the batch to 'pending' (re-checked every new-block tick and batch interval) so it sends as soon as outputs unlock — no manual intervention. Also classify insufficient-funds RPC errors as pre-broadcast/safe-to-retry: single -> pending (not failed), batch -> pending without an operator alert (not needs_review), since no tx went on-chain and there's no double-pay risk.
This commit is contained in:
+54
-12
@@ -17,6 +17,14 @@ const { ValidationError, normalizeError } = require('../utils/errors');
|
||||
const paymentConfig = require('../config/paymentConfig');
|
||||
const money = require('../money/atomic');
|
||||
|
||||
// A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it
|
||||
// is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero
|
||||
// locks spent outputs (incl. change) for ~10 blocks, so this is the expected error when all
|
||||
// outputs are temporarily locked.
|
||||
function isInsufficientFundsError(message) {
|
||||
return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i.test(String(message || ''));
|
||||
}
|
||||
|
||||
const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals
|
||||
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
|
||||
|
||||
@@ -1399,6 +1407,31 @@ class GameModeManager {
|
||||
|
||||
if (pending.rows.length === 0) return;
|
||||
|
||||
// MONERO OUTPUT LOCKING: a spent output (incl. the change output) is locked for
|
||||
// ~10 blocks. If we don't have enough UNLOCKED balance to cover this batch right
|
||||
// now, DEFER it (revert to pending) instead of attempting a transfer that would
|
||||
// fail — the next batch interval and every new-block tick re-check, so it sends as
|
||||
// soon as outputs unlock. Batching already minimises how many change outputs we
|
||||
// create; this handles the "all outputs currently locked" window.
|
||||
try {
|
||||
const totalNeeded = money.sum(pending.rows.map(p => p.amount)); // BigInt atomic
|
||||
const bal = await this.walletService.getBalance();
|
||||
// unlocked_balance arrives as a JS number from the RPC; coerce to integer BigInt.
|
||||
const unlocked = (bal && !bal.error) ? BigInt(Math.round(Number(bal.unlocked_balance) || 0)) : null;
|
||||
if (unlocked !== null && unlocked < totalNeeded) {
|
||||
const ids = pending.rows.map(p => p.id);
|
||||
await this.db.query(
|
||||
`UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW() WHERE id = ANY($1)`,
|
||||
[ids, 'Deferred: insufficient unlocked balance (Monero outputs locked ~10 blocks)']
|
||||
).catch(() => {});
|
||||
console.warn(`⏳ Deferring ${pending.rows.length} payout(s): unlocked ${unlocked} < needed ${totalNeeded}. Outputs likely locked; will retry next block/interval.`);
|
||||
return;
|
||||
}
|
||||
} catch (balErr) {
|
||||
// If the balance check itself fails, fall through and let the transfer try.
|
||||
console.error('Payout balance pre-check failed, proceeding:', balErr.message);
|
||||
}
|
||||
|
||||
console.log(`📦 Processing batch of ${pending.rows.length} pending payout(s)`);
|
||||
|
||||
// Build destinations for transfer_split
|
||||
@@ -1435,10 +1468,14 @@ class GameModeManager {
|
||||
});
|
||||
console.log(`💸 Single payout ${p.id} completed: ${result.txHash}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Single payout ${p.id} failed:`, err.message);
|
||||
// Insufficient-unlocked-funds is a pre-broadcast error -> retry on the next
|
||||
// batch run (revert to pending). Other failures -> 'failed' (retry service,
|
||||
// which re-checks the chain before re-sending).
|
||||
const status = isInsufficientFundsError(err.message) ? 'pending' : 'failed';
|
||||
console.error(`❌ Single payout ${p.id} failed -> ${status}:`, err.message);
|
||||
await this.db.query(
|
||||
`UPDATE payouts SET status = 'failed', last_error = $1, last_retry_at = NOW() WHERE id = $2`,
|
||||
[err.message, p.id]
|
||||
`UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = $3`,
|
||||
[status, err.message, p.id]
|
||||
).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
@@ -1473,17 +1510,22 @@ class GameModeManager {
|
||||
});
|
||||
console.log(`💸 Batch payout completed: ${pending.rows.length} payouts in tx ${txHash}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Batch payout failed (${pending.rows.length} payouts):`, err.message);
|
||||
// CRITICAL: a batch failure is ambiguous — transfer_split may have already
|
||||
// broadcast on-chain even though the RPC response errored. These rows have no
|
||||
// tx_hash, so the retry service's blockchain guard can't protect them and an
|
||||
// auto-retry could DOUBLE-PAY. Mark 'needs_review' (which the retry service
|
||||
// does NOT pick up) and alert an operator instead.
|
||||
// An insufficient-unlocked-funds error is raised BEFORE broadcast (no tx
|
||||
// went out), so it's safe to retry: revert to 'pending' and let the next
|
||||
// run send it once outputs unlock — no operator alert needed (transient).
|
||||
//
|
||||
// ANY OTHER error is ambiguous: transfer_split may have broadcast on-chain
|
||||
// even though the RPC response errored. Those rows have no tx_hash, so the
|
||||
// retry service's blockchain guard can't protect them and an auto-retry
|
||||
// could DOUBLE-PAY. Mark 'needs_review' (retry service skips it) + alert.
|
||||
const fundsIssue = isInsufficientFundsError(err.message);
|
||||
const status = fundsIssue ? 'pending' : 'needs_review';
|
||||
console.error(`❌ Batch payout failed (${pending.rows.length} payouts) -> ${status}:`, err.message);
|
||||
await this.db.query(
|
||||
`UPDATE payouts SET status = 'needs_review', last_error = $1, last_retry_at = NOW() WHERE id = ANY($2)`,
|
||||
[String(err.message).slice(0, 500), ids]
|
||||
`UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = ANY($3)`,
|
||||
[status, String(err.message).slice(0, 500), ids]
|
||||
).catch(() => {});
|
||||
if (this.alertService && typeof this.alertService.sendAlert === 'function') {
|
||||
if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') {
|
||||
this.alertService.sendAlert('batch_payout_failed', {
|
||||
subject: '⚠️ Batch payout failed — manual review required',
|
||||
html: `<p>A batch of ${pending.rows.length} payout(s) failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
|
||||
|
||||
@@ -89,6 +89,8 @@ describe('Batch payout dispatch', () => {
|
||||
|
||||
test('an ambiguous batch failure is marked needs_review, not failed', async () => {
|
||||
const walletService = {
|
||||
// Enough unlocked balance so the pre-flight check passes and we reach the transfer.
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn().mockRejectedValue(new Error('RPC timeout'))
|
||||
};
|
||||
@@ -101,11 +103,59 @@ describe('Batch payout dispatch', () => {
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
// Failure handling uses db.query (outside a transaction) to mark the batch needs_review.
|
||||
const failCall = db.query.mock.calls.find(c => /needs_review/i.test(c[0]));
|
||||
// Status is passed as a bound param ($1), so check the params array, not the SQL text.
|
||||
const failCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
|
||||
expect(failCall).toBeDefined();
|
||||
expect(failCall[0]).toMatch(/id = ANY/i);
|
||||
// Must NOT mark them 'failed' (which the retry service would auto-resend).
|
||||
const failedCall = db.query.mock.calls.find(c => /status = 'failed'/i.test(c[0]));
|
||||
const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed');
|
||||
expect(failedCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('MONERO LOCKING: defers the batch to pending when unlocked balance is insufficient (no transfer attempted)', async () => {
|
||||
// TWO_PENDING needs 200000000000 + 300000000000 = 500000000000 atomic.
|
||||
// Unlocked balance is far below that (outputs locked ~10 blocks) -> defer, do not send.
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '500000000000', unlocked_balance: '1000000000' }),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn()
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
// No on-chain transfer was attempted.
|
||||
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
|
||||
// The defer UPDATE sets status = 'pending' literally in SQL and passes ids as $1.
|
||||
const deferSql = db.query.mock.calls.find(c => /SET status = 'pending'/i.test(c[0]) && /id = ANY/i.test(c[0]));
|
||||
expect(deferSql).toBeDefined();
|
||||
expect(deferSql[1][0]).toEqual([10, 11]); // ids array param
|
||||
});
|
||||
|
||||
test('a pre-broadcast insufficient-funds batch error is retried (pending), not needs_review', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn().mockRejectedValue(new Error('not enough unlocked money to transfer'))
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
// A funds error means nothing broadcast -> safe to retry: status='pending', NOT needs_review.
|
||||
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = ANY/i.test(c[0]));
|
||||
expect(pendingCall).toBeDefined();
|
||||
const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
|
||||
expect(reviewCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user