feat: initial batch payout logic and output splitting script added

This commit is contained in:
jwinterm
2026-02-18 07:30:46 -05:00
parent bf5938c366
commit ec3caabf1c
6 changed files with 477 additions and 71 deletions
+22
View File
@@ -340,6 +340,28 @@ WantedBy=multi-user.target
---
## Wallet Output Management
Wownero (and Monero) lock change outputs for several blocks after spending. If the wallet has only one large output and multiple payouts fire in quick succession, the second payout will fail because the change output from the first is still locked.
**Solution**: Pre-split wallet outputs into many smaller ones. The game server includes a CLI tool for this:
```bash
# Preview what would happen (no transaction sent)
node scripts/splitOutputs.js --amount 10 --count 30 --dry-run
# Split into 30 outputs of 10 WOW each (requires ~300 WOW + fees)
node scripts/splitOutputs.js --amount 10 --count 30
```
This creates 30 independently-spendable outputs, allowing up to 30 concurrent payouts before any output locking becomes an issue.
**When to re-split**: As payouts go out, outputs get consumed and consolidated via change. Periodically check the wallet and re-split when the number of spendable outputs drops low. The server also batches payouts that happen within 5 seconds of each other into a single transaction to conserve outputs.
**Recommended setup**: After initial wallet funding, run the split script. For a game with typical traffic, 20-30 outputs of 10 WOW each provides good concurrency headroom.
---
## Monitoring
### Check Service Status
+12
View File
@@ -87,6 +87,16 @@
- [x] **Admin UI**: Full dashboard at `/admin.html` with charts and tables
- [x] **Payout Retry**: Manual retry button in admin dashboard
## Payout Reliability (Feb 2026)
- [x] **Fire-and-Forget INSERT Fix**: `_insertGameRecord` now awaited, prevents race conditions ✅
- [x] **Double Game-Over Prevention**: `activeGames.delete()` moved before any `await` in `handleGameOver` ✅
- [x] **DB-Level Duplicate Payout Prevention**: Unique partial index on `payouts(game_id)` (migration 012) ✅
- [x] **Payout Address Locking**: Address captured at game start, stored in `games.payout_address` ✅
- [x] **Dead Code Removal**: Deleted unused `processGameCompletion()` method ✅
- [x] **Same-Block Payout Batching**: Payouts within 5-second window batched into one `transfer_split` call ✅
- [x] **Output Splitting Script**: `scripts/splitOutputs.js` for wallet output management ✅
- [x] **transfer_split**: Switched from `transfer` to `transfer_split` for output resilience ✅
## Pre-Production Testing Checklist
### Security Verification
@@ -120,6 +130,8 @@
- [x] **Payout Retry Queue**: Automatic retry mechanism via `payoutRetryService.js` ✅
- [x] **Address Validation**: Wallet RPC `validate_address` method ✅
- [ ] **Redis Rate Limiting**: Replace in-memory rate limiter with Redis for persistence across restarts
- [ ] **Full Payout Batching**: Implement configurable batch intervals beyond same-block 5-second window
- [ ] **Wallet Health Monitoring**: Track spendable output count, alert when outputs are low
### Frontend
- [ ] **Socket Reconnection**: Add `reconnect` and `disconnect` handlers to restore state after connection loss
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env node
/**
* splitOutputs.js — Split wallet outputs for concurrent payout capacity.
*
* Wownero/Monero lock change outputs for several blocks after spending.
* Pre-splitting one large output into many smaller ones allows the wallet
* to handle multiple concurrent payouts without "not enough unlocked balance".
*
* Usage:
* node scripts/splitOutputs.js [options]
*
* Options:
* --amount <WOW> Target output size in WOW (default: 10)
* --count <N> Number of outputs to create (default: 20)
* --dry-run Show what would happen without sending
* --endpoint <url> Wallet RPC endpoint (overrides PRIMARY_WALLET_ENDPOINT)
*/
const path = require('path');
const axios = require('axios');
// Load .env from the src/ directory (same as server)
require('dotenv').config({ path: path.resolve(__dirname, '..', 'src', '.env') });
const ATOMIC_DIVISOR = 1e11; // Wownero: 11 decimal places
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
amount: 10,
count: 20,
dryRun: false,
endpoint: process.env.PRIMARY_WALLET_ENDPOINT || 'http://127.0.0.1:34570'
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--amount':
opts.amount = parseFloat(args[++i]);
if (!opts.amount || opts.amount <= 0) {
console.error('Error: --amount must be a positive number');
process.exit(1);
}
break;
case '--count':
opts.count = parseInt(args[++i], 10);
if (!opts.count || opts.count <= 0) {
console.error('Error: --count must be a positive integer');
process.exit(1);
}
break;
case '--dry-run':
opts.dryRun = true;
break;
case '--endpoint':
opts.endpoint = args[++i];
break;
case '--help':
case '-h':
console.log(`
splitOutputs.js — Split wallet outputs for concurrent payout capacity.
Usage: node scripts/splitOutputs.js [options]
Options:
--amount <WOW> Target output size in WOW (default: 10)
--count <N> Number of outputs to create (default: 20)
--dry-run Show what would happen without sending
--endpoint <url> Wallet RPC endpoint (default: PRIMARY_WALLET_ENDPOINT env)
--help Show this help message
Example:
node scripts/splitOutputs.js --amount 10 --count 30
Creates 30 outputs of 10 WOW each (requires 300+ WOW unlocked balance).
`);
process.exit(0);
break;
default:
console.error(`Unknown option: ${args[i]}`);
process.exit(1);
}
}
return opts;
}
async function rpcCall(endpoint, method, params = {}) {
const response = await axios.post(`${endpoint}/json_rpc`, {
jsonrpc: '2.0',
id: '0',
method,
params
});
if (response.data.error) {
throw new Error(`RPC error (${method}): ${response.data.error.message}`);
}
return response.data;
}
async function main() {
const opts = parseArgs();
const amountAtomic = Math.round(opts.amount * ATOMIC_DIVISOR);
const totalNeeded = amountAtomic * opts.count;
console.log('=== Wownero Output Splitter ===');
console.log(`Endpoint: ${opts.endpoint}`);
console.log(`Output size: ${opts.amount} WOW (${amountAtomic} atomic)`);
console.log(`Count: ${opts.count} outputs`);
console.log(`Total: ${(totalNeeded / ATOMIC_DIVISOR).toFixed(4)} WOW (+ fees)`);
if (opts.dryRun) console.log('Mode: DRY RUN');
console.log('');
// Step 1: Get wallet address
let ownAddress;
try {
const addrResp = await rpcCall(opts.endpoint, 'get_address', {
account_index: 0,
address_index: [0]
});
ownAddress = addrResp.result.address;
console.log(`Wallet: ${ownAddress.substring(0, 12)}...${ownAddress.substring(ownAddress.length - 6)}`);
} catch (err) {
console.error(`Failed to get wallet address: ${err.message}`);
console.error('Make sure wownero-wallet-rpc is running on', opts.endpoint);
process.exit(1);
}
// Step 2: Check balance
let balance;
try {
const balResp = await rpcCall(opts.endpoint, 'get_balance', { account_index: 0 });
balance = balResp.result;
console.log(`Balance: ${(balance.balance / ATOMIC_DIVISOR).toFixed(4)} WOW`);
console.log(`Unlocked: ${(balance.unlocked_balance / ATOMIC_DIVISOR).toFixed(4)} WOW`);
console.log('');
} catch (err) {
console.error(`Failed to get balance: ${err.message}`);
process.exit(1);
}
// Rough fee estimate: ~0.01 WOW per output
const estimatedFees = Math.round(0.01 * ATOMIC_DIVISOR * opts.count);
const totalWithFees = totalNeeded + estimatedFees;
if (balance.unlocked_balance < totalWithFees) {
console.error(`Insufficient unlocked balance!`);
console.error(` Need: ~${(totalWithFees / ATOMIC_DIVISOR).toFixed(4)} WOW (including estimated fees)`);
console.error(` Have: ${(balance.unlocked_balance / ATOMIC_DIVISOR).toFixed(4)} WOW unlocked`);
process.exit(1);
}
// Step 3: Build destinations (all to own address)
const destinations = [];
for (let i = 0; i < opts.count; i++) {
destinations.push({
amount: amountAtomic,
address: ownAddress
});
}
console.log(`Splitting into ${opts.count} outputs of ${opts.amount} WOW each...`);
if (opts.dryRun) {
console.log('');
console.log('DRY RUN — no transaction sent.');
console.log(`Would create ${opts.count} outputs of ${amountAtomic} atomic units each.`);
console.log(`Estimated total (with fees): ~${(totalWithFees / ATOMIC_DIVISOR).toFixed(4)} WOW`);
console.log('Remaining balance would be a change output.');
process.exit(0);
}
// Step 4: Send transfer_split to self
try {
const txResp = await rpcCall(opts.endpoint, 'transfer_split', {
destinations,
account_index: 0,
priority: 1,
get_tx_key: true
});
const result = txResp.result;
const totalFee = (result.fee_list || []).reduce((a, b) => a + b, 0);
console.log('');
console.log('=== Split Complete ===');
console.log(`Transactions: ${result.tx_hash_list.length}`);
for (const txHash of result.tx_hash_list) {
console.log(` tx: ${txHash}`);
}
console.log(`Total fee: ${(totalFee / ATOMIC_DIVISOR).toFixed(6)} WOW`);
console.log(`Outputs created: ${opts.count}`);
console.log('');
console.log('Outputs will be spendable after 4 block confirmations (~8 minutes).');
} catch (err) {
console.error(`Failed to split outputs: ${err.message}`);
process.exit(1);
}
}
main().catch(err => {
console.error('Unexpected error:', err.message);
process.exit(1);
});
+146 -59
View File
@@ -1136,89 +1136,42 @@ class GameModeManager {
const userRow = userResult.rows[0];
const payoutAddress = lockedPayoutAddress || (userRow && userRow.payout_address);
if (userRow && payoutAddress) {
// SECURITY: Use safe payout pattern - create pending record FIRST, then RPC, then store tx_hash
let payoutId = null;
// Create pending payout record, then schedule batch processing.
// Multiple wins within 5 seconds are batched into one transfer_split call,
// saving outputs and preventing locked-output failures.
try {
// Step 1: Create pending payout record FIRST (ensures we have a record even if RPC succeeds but server crashes)
const insertResult = await this.db.query(`
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW())
RETURNING id
`, [
userRow.id,
gameDbId, // Use DB id if available
gameDbId,
payoutAddress,
payoutAmount,
multiplier,
treasureFound ? 'escape_with_treasure' : 'escape'
]);
payoutId = insertResult.rows[0].id;
const payoutId = insertResult.rows[0].id;
// Step 2: Attempt wallet RPC transfer (using locked address)
const payoutResult = await this.walletService.processPayout({
userId: userRow.id,
gameId,
address: payoutAddress,
amount: payoutAmount,
multiplier,
description: `Game ${gameId} payout`
});
// Step 3: IMMEDIATELY store tx_hash if RPC succeeded (BEFORE any transaction)
// This ensures we never lose the tx_hash even if subsequent DB operations fail
if (payoutResult.success && payoutResult.txHash) {
await this.db.query(`
UPDATE payouts SET tx_hash = $1, fee = $2 WHERE id = $3
`, [payoutResult.txHash, payoutResult.fee || null, payoutId]);
}
// Step 4: Update status and user stats atomically
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE payouts
SET status = $1, processed_at = NOW()
WHERE id = $2
`, [payoutResult.success ? 'completed' : 'failed', payoutId]);
if (payoutResult.success) {
await client.query(`
UPDATE users
SET total_amount_won = total_amount_won + $1,
total_payouts_received = COALESCE(total_payouts_received, 0) + 1
WHERE id = $2
`, [payoutAmount, userRow.id]);
}
});
// Schedule batch processing (5-second debounce collects concurrent wins)
this._scheduleBatchPayout();
return {
success: true,
mode: recordedPaymentMode,
payout: {
status: 'queued',
payoutId,
amount: payoutAmount,
multiplier,
treasure: treasureFound,
txHash: payoutResult.txHash
treasure: treasureFound
},
score: metrics.score ?? null
};
} catch (payoutErr) {
const normalizedPayout = normalizeError(payoutErr, 'Failed to send payout');
console.error('❌ Error creating payout:', normalizedPayout.message);
// Update payout record with failure if it exists
if (payoutId) {
try {
await this.db.query(`
UPDATE payouts
SET status = 'failed',
error_message = $1,
last_error = $1,
last_retry_at = NOW()
WHERE id = $2
`, [payoutErr.message, payoutId]);
} catch (dbErr) {
console.error('Failed to update payout error:', dbErr.message);
}
}
const normalizedPayout = normalizeError(payoutErr, 'Failed to create payout record');
console.error('❌ Error creating payout record:', normalizedPayout.message);
return { success: true, mode: recordedPaymentMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null };
}
}
@@ -1233,6 +1186,140 @@ class GameModeManager {
return { success: false, error: normalized.safeMessage };
}
}
// ---- Batch Payout Processing ----
/**
* Schedule batch payout processing with a short debounce.
* Multiple wins within the debounce window (5s) are batched into one transfer.
* Also called on new block events as a safety net.
*/
_scheduleBatchPayout() {
if (this._batchPayoutTimer) clearTimeout(this._batchPayoutTimer);
this._batchPayoutTimer = setTimeout(() => {
this._processPendingPayouts().catch(err => {
console.error('❌ Batch payout processing error:', err.message);
});
}, 5000);
}
/**
* Process all pending payouts that haven't been sent yet.
* Batches them into a single transfer_split call for efficiency.
* Called by debounce timer and on new block events.
*/
async _processPendingPayouts() {
// Prevent concurrent batch processing
if (this._isBatchProcessing) return;
this._isBatchProcessing = true;
try {
// Gather all pending payouts with no tx_hash (not yet attempted)
const pending = await this.db.query(`
SELECT id, user_id, game_id, payout_address, amount, multiplier, reason
FROM payouts
WHERE status = 'pending' AND tx_hash IS NULL
ORDER BY created_at ASC
`);
if (pending.rows.length === 0) return;
console.log(`📦 Processing batch of ${pending.rows.length} pending payout(s)`);
// Build destinations for transfer_split
const destinations = pending.rows.map(p => ({
amount: p.amount,
address: p.payout_address
}));
// Generate batch_id to link payouts processed together
const batchId = require('uuid').v4();
if (pending.rows.length === 1) {
// Single payout — use processPayout for simpler flow
const p = pending.rows[0];
try {
const result = await this.walletService.processPayout({
userId: p.user_id,
gameId: p.game_id,
address: p.payout_address,
amount: p.amount,
multiplier: p.multiplier
});
if (result.success && result.txHash) {
await this.db.query(
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3 WHERE id = $4`,
[result.txHash, result.fee || null, batchId, p.id]
);
}
await this.db.withTransaction(async (client) => {
await client.query(
`UPDATE payouts SET status = 'completed', processed_at = NOW() WHERE id = $1`,
[p.id]
);
await client.query(
`UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`,
[p.amount, p.user_id]
);
});
console.log(`💸 Single payout ${p.id} completed: ${result.txHash}`);
} catch (err) {
console.error(`❌ Single payout ${p.id} failed:`, 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]
).catch(() => {});
}
} else {
// Multiple payouts — batch via transfer_split with multiple destinations
try {
const result = await this.walletService.processBatchPayout(destinations);
// All destinations in one transfer_split typically produce 1 tx
const txHash = result.tx_hash_list[0];
const feePerPayout = result.totalFee
? Math.ceil(result.totalFee / pending.rows.length)
: null;
for (const p of pending.rows) {
// Store tx_hash immediately for each payout
await this.db.query(
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3 WHERE id = $4`,
[txHash, feePerPayout, batchId, p.id]
);
// Update status and user stats atomically
await this.db.withTransaction(async (client) => {
await client.query(
`UPDATE payouts SET status = 'completed', processed_at = NOW() WHERE id = $1`,
[p.id]
);
await client.query(
`UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`,
[p.amount, p.user_id]
);
});
}
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);
// Mark all as failed — retry service will pick them up individually
for (const p of pending.rows) {
await this.db.query(
`UPDATE payouts SET status = 'failed', last_error = $1, last_retry_at = NOW() WHERE id = $2`,
[err.message, p.id]
).catch(() => {});
}
}
}
} catch (err) {
console.error('❌ _processPendingPayouts error:', err.message);
} finally {
this._isBatchProcessing = false;
}
}
}
module.exports = GameModeManager;
+4
View File
@@ -1686,6 +1686,10 @@ debugManager.onNewBlockCallback((blockHeight) => {
socketHandlers.startGamesForWaiting(blockHeight);
// Check active games for timeout
socketHandlers.checkGamesTimeout(blockHeight);
// Flush any pending payouts (safety net for batch timer)
gameModeManager._processPendingPayouts().catch(err => {
console.error('❌ Block-triggered payout processing error:', err.message);
});
});
// Initialize payment system
+90 -12
View File
@@ -272,14 +272,68 @@ class WalletRPCService {
}
/**
* Placeholder for future batch payout aggregation.
* Currently returns early to avoid runtime errors where it's scheduled.
* Send a batch payout to multiple destinations in a single transfer_split call.
* More efficient than individual transfers — uses fewer outputs and pays less in fees.
* @param {Array<{amount: number|bigint, address: string}>} destinations
* @returns {Object} - { tx_hash_list, tx_key_list, fee_list, totalFee }
*/
async processBatchPayouts() {
if (this.debugManager?.CONSOLE_LOGGING) {
console.log('⚙️ processBatchPayouts stub invoked (no payouts processed).');
async processBatchPayout(destinations) {
if (!destinations || destinations.length === 0) {
throw new AppError('No destinations provided for batch payout', {
safeMessage: 'Batch payout requires at least one destination.'
});
}
return { processed: 0 };
// Validate all addresses before attempting transfer
for (const dest of destinations) {
if (!dest.address || typeof dest.address !== 'string') {
throw new AppError('Invalid address in batch payout', {
safeMessage: 'One or more payout addresses are invalid.'
});
}
const validation = await this.validateAddress(dest.address);
if (!validation.valid) {
throw new AppError(`Invalid payout address in batch: ${dest.address.substring(0, 10)}...`, {
safeMessage: 'One or more payout addresses failed validation.'
});
}
}
const transferParams = {
destinations: destinations.map(d => ({
amount: this.normalizeAtomicAmount(d.amount),
address: d.address
})),
account_index: this.accountIndex,
priority: 1,
get_tx_key: true
};
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`🚀 Initiating batch payout (${destinations.length} destinations)`, {
totalAmount: destinations.reduce((sum, d) => sum + Number(d.amount), 0),
destinations: destinations.map(d => d.address.substring(0, 10) + '...')
});
}
const response = await this.rpcCall('transfer_split', transferParams);
const result = {
tx_hash_list: response.result.tx_hash_list || [],
tx_key_list: response.result.tx_key_list || [],
fee_list: response.result.fee_list || [],
totalFee: (response.result.fee_list || []).reduce((a, b) => a + b, 0)
};
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`💸 Batch payout sent (${result.tx_hash_list.length} tx)`, {
txHashes: result.tx_hash_list,
totalFee: result.totalFee,
destinations: destinations.length
});
}
return result;
}
normalizeAtomicAmount(value) {
@@ -342,6 +396,22 @@ class WalletRPCService {
}
}
/**
* Get the wallet's own primary address (account_index 0, address_index 0).
* Used by output-splitting script and internal operations.
* @returns {Object} - { address: string, address_index: number }
*/
async getOwnAddress() {
const response = await this.rpcCall('get_address', {
account_index: this.accountIndex,
address_index: [0]
});
return {
address: response.result.address,
address_index: 0
};
}
/**
* Validate a payout address using wallet RPC
* @param {string} address - The address to validate
@@ -420,22 +490,30 @@ class WalletRPCService {
});
}
const response = await this.rpcCall('transfer', transferParams);
// Use transfer_split for better output handling — automatically splits
// across multiple outputs if needed, more resilient than plain transfer
const response = await this.rpcCall('transfer_split', transferParams);
// transfer_split returns arrays: tx_hash_list, tx_key_list, fee_list
const txHash = response.result.tx_hash_list?.[0];
const txKey = response.result.tx_key_list?.[0];
const fee = response.result.fee_list?.reduce((a, b) => a + b, 0) || 0;
if (this.debugManager?.CONSOLE_LOGGING) {
console.log('💸 Payout sent successfully', {
to: address.substring(0, 10) + '...',
amount: normalizedAmount,
txHash: response.result.tx_hash,
fee: response.result.fee
txHash,
fee,
txCount: response.result.tx_hash_list?.length || 1
});
}
return {
success: true,
txHash: response.result.tx_hash,
txKey: response.result.tx_key,
fee: response.result.fee,
txHash,
txKey,
fee,
userId,
gameId,
amount: normalizedAmount