Limit payment requests

This commit is contained in:
jwinterm
2025-09-30 07:03:41 -04:00
parent 4e7a773496
commit fa61d60bc0
4 changed files with 146 additions and 49 deletions
+6 -2
View File
@@ -327,8 +327,12 @@ const SocketHandlers = {
console.log('Payment created:', data);
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
const parts = [];
parts.push('💳 <strong>Payment Required</strong>');
parts.push('Amount: ' + data.humanAmount + ' ' + data.cryptoType);
const reusedCopy = data.reused
? '🔁 <strong>Pending payment request still active.</strong> Use the same amount and address below.'
: '💳 <strong>Payment Required</strong>';
parts.push(reusedCopy);
const displayAmount = data.humanAmount || data.amountFormatted || data.amount;
parts.push('Amount: ' + displayAmount + ' ' + (data.cryptoType || ''));
const shortAddr = data.address.substring(0, 10) + '…' + data.address.slice(-6);
parts.push('Address: <span class="pay-address-full" style="cursor:pointer;" title="Click to toggle full address">' + shortAddr + '</span>' +
' <button class="copy-pay-address" data-address="' + data.address + '" style="margin-left:4px;padding:1px 4px;font-size:11px;cursor:pointer;">Copy</button>');
+39 -14
View File
@@ -13,6 +13,9 @@ const AudioAlerts = {
_lastPlay: 0,
_cooldownMs: 1500,
_audioTags: {}, // cache HTMLAudioElements
_audioDurations: {},
_sequentialBufferMs: 600,
_minGameStartDelayMs: 2800,
_gameStartHoldUntil: 0,
_pendingGameStartTimer: null,
_fileMap: {
@@ -130,25 +133,33 @@ const AudioAlerts = {
if (!src) return null;
const el = new Audio(src);
el.preload = 'auto';
el.addEventListener('loadedmetadata', () => {
if (isFinite(el.duration) && el.duration > 0) {
this._audioDurations[tag] = Math.ceil(el.duration * 1000);
}
}, { once: true });
this._audioTags[tag] = el;
}
return this._audioTags[tag];
},
playFile(tag) {
playFile(tag, options = {}) {
if (!this._enabled) return null;
const force = !!options.force;
const now = Date.now();
// Per-tag cooldown so different event types can fire back-to-back (e.g. payment_confirmed -> game_start)
if (!this._audioTagTimes) this._audioTagTimes = {}; // tag -> last play timestamp
const lastForTag = this._audioTagTimes[tag] || 0;
const elapsed = now - lastForTag;
const allow = elapsed >= this._cooldownMs || (tag === 'game_start' && this._lastTag !== 'game_start');
const allow = force || elapsed >= this._cooldownMs || (tag === 'game_start' && this._lastTag !== 'game_start');
if (!allow) return null;
this._audioTagTimes[tag] = now;
this._lastTag = tag;
const el = this._getAudio(tag);
if (!el) return null;
try {
el.pause();
el.currentTime = 0;
el.volume = this._volume;
el.play().catch(()=>{});
} catch(_) {}
return el;
@@ -156,11 +167,14 @@ const AudioAlerts = {
playPaymentConfirmed() {
if (!this._enabled) return;
const el = this.playFile('payment_confirmed');
const fallback = 2200;
let holdMs = fallback;
if (el && isFinite(el.duration) && el.duration > 0.2) {
holdMs = Math.ceil(el.duration * 1000);
}
const storedDuration = this._audioDurations?.payment_confirmed || 0;
const measuredMs = (el && isFinite(el.duration) && el.duration > 0.2)
? Math.ceil(el.duration * 1000)
: storedDuration;
const holdMs = Math.max(
this._minGameStartDelayMs,
(measuredMs || 0) + this._sequentialBufferMs
);
this._gameStartHoldUntil = Date.now() + holdMs;
},
playGameStart() {
@@ -183,25 +197,36 @@ const AudioAlerts = {
},
playGameOverSound(gameOverData) {
if (!this._enabled) return;
if (!gameOverData || !gameOverData.reason) return;
if (!gameOverData) return;
let reason = (gameOverData.reason || '').toLowerCase();
let soundTag = '';
switch (gameOverData.reason) {
switch (reason) {
case 'escaped':
// Check if they escaped with treasure
soundTag = gameOverData.treasure ? 'game_escaped_treasure' : 'game_escaped';
break;
case 'treasure_escape':
soundTag = 'game_escaped_treasure';
break;
case 'timeout':
soundTag = 'game_timeout';
break;
case 'monster':
case 'monster_caught':
case 'caught':
soundTag = 'game_monster';
break;
default:
return; // Unknown reason, no audio
if (gameOverData.status === 'won') {
soundTag = gameOverData.treasure ? 'game_escaped_treasure' : 'game_escaped';
} else if (gameOverData.status === 'lost') {
soundTag = reason === 'timeout' ? 'game_timeout' : 'game_monster';
}
}
if (soundTag) {
this.playFile(soundTag, { force: true });
}
this.playFile(soundTag);
},
// Public helper to be invoked when server denies start due to payment/credits
playRequestCoin() { this.playFile('request_coin'); }
+61 -9
View File
@@ -239,6 +239,38 @@ class GameModeManager {
return { amount, multiplier, base };
}
async _findReusablePayment(userId, paymentType) {
if (!userId) return null;
const result = await this.db.query(`
SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description
FROM payments
WHERE user_id = $1
AND payment_type = $2
AND status = 'pending'
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC
LIMIT 1
`, [userId, paymentType]);
return result.rows[0] || null;
}
_mapPaymentRowToRequest(row, paymentType, packageInfo) {
if (!row) return null;
const amount = Number(row.expected_amount);
return {
id: row.id,
address: row.subaddress,
amount,
amountFormatted: this.formatAtomicHuman(amount, 4),
currency: this.cryptoType,
expiresAt: row.expires_at,
paymentType,
description: row.description,
package: packageInfo || null,
reused: true
};
}
logConfiguration(context = 'initialized') {
console.log(`🎮 Game Mode Manager ${context}: ${this.gameMode} mode`);
console.log(`💰 Currency: ${this.cryptoType} (decimals: ${this.currencyDecimals})`);
@@ -465,10 +497,11 @@ class GameModeManager {
/**
* Create payment request
*/
async createPaymentRequest(socketId, paymentType) {
async createPaymentRequest(socketId, paymentType, options = {}) {
try {
const user = await this.getOrCreateUser(socketId);
const reuseExisting = options.reuseExisting !== false;
let amount;
let description;
let packageInfo = null;
@@ -493,6 +526,17 @@ class GameModeManager {
safeMessage: 'Unsupported payment type requested.'
});
}
if (reuseExisting) {
const existingRow = await this._findReusablePayment(user.id, paymentType);
if (existingRow) {
const existing = this._mapPaymentRowToRequest(existingRow, paymentType, packageInfo);
if (existing && !existing.description) {
existing.description = description;
}
return existing;
}
}
// Create payment request using wallet RPC with correct parameters
const paymentResult = await this.walletService.createPaymentRequest(
@@ -502,20 +546,28 @@ class GameModeManager {
socketId
);
const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000);
// Store payment info in database
await this.db.query(`
INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, created_at)
VALUES ($1, $2, $3, $4, $5, 'pending', NOW())
`, [user.id, socketId, paymentResult.address, amount, paymentType]);
const insertResult = await this.db.query(`
INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7)
RETURNING id, expires_at
`, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt]);
const insertedRow = insertResult.rows[0];
return {
id: paymentResult.id,
id: insertedRow?.id,
address: paymentResult.address,
amount: amount,
amountFormatted: this.formatAtomicHuman(amount, 4),
currency: this.cryptoType,
expiresAt: paymentResult.expiresAt,
package: packageInfo
expiresAt: insertedRow?.expires_at || expiresAt,
package: packageInfo,
paymentType,
description,
reused: false
};
} catch (error) {
+40 -24
View File
@@ -16,8 +16,8 @@ class PaymentHandlers {
this.broadcastManager = broadcastManager;
this.mempoolNotified = new Set();
this.paymentMonitors = new Map();
// Track active monitored address per socket so we can stop on disconnect/expiry
this.socketAddressMap = new Map();
// Track pending payment metadata per socket (may reuse existing)
this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt }
// Track paymentIds already confirmed (avoid duplicate emits / queue actions)
this.confirmedPayments = new Set();
// Periodic cleanup (confirmed payment IDs older than retention window)
@@ -108,7 +108,8 @@ class PaymentHandlers {
return;
}
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType);
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, { reuseExisting: true });
const reused = !!paymentRequest.reused;
const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3);
let qrDataUrl = null;
try {
@@ -127,21 +128,31 @@ class PaymentHandlers {
address: paymentRequest.address,
amount: paymentRequest.amount,
amountFormatted: formattedAmount,
humanAmount: formattedAmount,
paymentType,
gameMode,
cryptoType,
description,
expiresAt: paymentRequest.expiresAt,
qr: qrDataUrl,
package: paymentRequest.package
package: paymentRequest.package,
reused
});
const statusHeader = reused
? '🔁 Existing payment request still pending. Use the details below to pay.'
: `💳 PAYMENT REQUIRED (${description})`;
const statusBody = `\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`;
this.broadcastManager.sendStatusUpdate(
socket.id,
'payment',
`💳 PAYMENT REQUIRED (${description})\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`
`${statusHeader}${statusBody}`
);
// If we reused an existing request, ensure we refresh monitoring to avoid duplicate watchers
this.stopMonitoringForSocket(socket.id);
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser);
} catch (e) {
const err = normalizeError(e, 'Failed to create payment request');
@@ -151,8 +162,14 @@ class PaymentHandlers {
}
_monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser) {
// Record mapping so we can stop later
this.socketAddressMap.set(socket.id, paymentRequest.address);
// Record mapping so we can stop later (replace existing entry)
this.socketPaymentMap.set(socket.id, {
address: paymentRequest.address,
paymentId: paymentRequest.id,
amount: paymentRequest.amount,
cryptoType,
createdAt: Date.now()
});
this.walletService.startPaymentMonitoring(paymentRequest.address, async (status) => {
if (status.in_mempool && !status.confirmed) {
@@ -186,15 +203,14 @@ class PaymentHandlers {
// Expire request after 30 minutes if not confirmed
const expiryTimeout = setTimeout(() => {
const address = this.socketAddressMap.get(socket.id);
if (address === paymentRequest.address) { // still active and not replaced
const mapping = this.socketPaymentMap.get(socket.id);
if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced
this.stopMonitoringForSocket(socket.id);
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
}
}, 30 * 60 * 1000);
// Store & unref so tests / process can exit
this._expiryTimeouts.set(socket.id, expiryTimeout);
if (expiryTimeout.unref) expiryTimeout.unref();
}
async handlePaymentDetected(socketId, paymentRequest, paymentStatus) {
@@ -212,15 +228,15 @@ class PaymentHandlers {
} else if (paymentStatus.confirmed) {
this.broadcastManager.sendStatusUpdate(socketId, 'success', `💰 PAYMENT CONFIRMED IN BLOCK\n\n✅ Added to queue.\n🕒 Starts at block ${nextBlock}.\n📦 Current block: ${currentBlock}`);
}
const newBalRes = await this.db.query('SELECT credits FROM users WHERE id = $1', [currentUser.id]);
const remaining = newBalRes.rows[0] ? newBalRes.rows[0].credits : currentUser.credits;
if (this.io) {
this.io.to(socketId).emit('credits_update', { balance: remaining });
}
if (!this.confirmedPayments.has(paymentRequest.id)) {
this.confirmedPayments.add(paymentRequest.id);
this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock });
this._confirmedTimestamps.set(paymentRequest.id, Date.now());
const newBalRes = await this.db.query('SELECT credits FROM users WHERE id = $1', [currentUser.id]);
const remaining = newBalRes.rows[0] ? newBalRes.rows[0].credits : currentUser.credits;
if (this.io) {
this.io.to(socketId).emit('credits_update', { balance: remaining });
}
if (!this.confirmedPayments.has(paymentRequest.id)) {
this.confirmedPayments.add(paymentRequest.id);
this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock });
this._confirmedTimestamps.set(paymentRequest.id, Date.now());
}
}
@@ -228,10 +244,10 @@ class PaymentHandlers {
* Stop monitoring (if any) for a given socket (disconnect/expiry)
*/
stopMonitoringForSocket(socketId) {
const address = this.socketAddressMap.get(socketId);
if (address) {
this.walletService.stopPaymentMonitoring(address);
this.socketAddressMap.delete(socketId);
const mapping = this.socketPaymentMap.get(socketId);
if (mapping) {
this.walletService.stopPaymentMonitoring(mapping.address);
this.socketPaymentMap.delete(socketId);
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🛑 Stopped monitoring for socket ${socketId}`);
}
@@ -251,7 +267,7 @@ class PaymentHandlers {
clearInterval(this._cleanupInterval);
this._cleanupInterval = null;
}
for (const [socketId] of this.socketAddressMap.entries()) {
for (const socketId of Array.from(this.socketPaymentMap.keys())) {
this.stopMonitoringForSocket(socketId);
}
for (const [socketId, to] of this._expiryTimeouts.entries()) {