mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
feat: initial batch payout logic and output splitting script added
This commit is contained in:
+146
-59
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user