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
+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