Files
wownerogue/test/batchPayout.test.js
T
jwinterm db5ed2e8aa 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.
2026-06-01 08:17:05 -04:00

162 lines
7.7 KiB
JavaScript

/**
* Batch payout dispatch tests (Phase 0.3).
*
* transfer_split sends ONE on-chain tx to many destinations, so every payout row in a
* batch shares that tx_hash. These tests guard:
* - the whole batch is marked completed with the shared tx_hash in one transaction
* (no row stranded mid-batch by a unique-index collision), and
* - an ambiguous batch failure is marked 'needs_review' (NOT 'failed'), so the retry
* service won't re-send and double-pay.
*/
const GameModeManager = require('../src/game/gameModeManager');
const createMockDb = () => {
const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) };
return {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)),
_mockClient: mockClient
};
};
const createMockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: {
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm(walletService) {
const db = createMockDb();
const debugManager = { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 };
const gmm = new GameModeManager(db, walletService, debugManager, createMockPaymentConfig());
return { gmm, db };
}
const TWO_PENDING = [
{ id: 10, user_id: 1, game_id: 'g1', payout_address: 'addrA', amount: '200000000000', multiplier: 2, reason: 'escape' },
{ id: 11, user_id: 2, game_id: 'g2', payout_address: 'addrB', amount: '300000000000', multiplier: 3, reason: 'escape' }
];
describe('Batch payout dispatch', () => {
test('marks the whole batch completed with the shared tx_hash in one transaction', async () => {
const walletService = {
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockResolvedValue({ tx_hash_list: ['txSHARED'], totalFee: 200 })
};
const { gmm, db } = buildGmm(walletService);
// First withTransaction = gather: SELECT returns 2 pending rows, then UPDATE ... processing.
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING }) // SELECT ... FOR UPDATE SKIP LOCKED
.mockResolvedValue({ rows: [] }); // everything else
await gmm._processPendingPayouts();
// The batch RPC was called once with both destinations.
expect(walletService.processBatchPayout).toHaveBeenCalledTimes(1);
expect(walletService.processBatchPayout.mock.calls[0][0]).toHaveLength(2);
// The completion UPDATE set the shared tx_hash on the whole batch via id = ANY(...).
const calls = db._mockClient.query.mock.calls.map(c => c[0]);
const batchUpdate = calls.find(sql => /UPDATE payouts SET tx_hash/i.test(sql) && /id = ANY/i.test(sql));
expect(batchUpdate).toBeDefined();
expect(batchUpdate).toMatch(/status = 'completed'/);
const batchUpdateCall = db._mockClient.query.mock.calls.find(c => /UPDATE payouts SET tx_hash/i.test(c[0]) && /id = ANY/i.test(c[0]));
expect(batchUpdateCall[1][0]).toBe('txSHARED'); // tx_hash param
expect(batchUpdateCall[1][3]).toEqual([10, 11]); // ids array
// Per-user stats incremented for each winner inside the same transaction (BIGINT, no float).
const statUpdates = calls.filter(sql => /UPDATE users SET total_amount_won/i.test(sql));
expect(statUpdates.length).toBe(2);
});
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'))
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
// Failure handling uses db.query (outside a transaction) to mark the batch needs_review.
// 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 => 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();
});
});