diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 4ddab21..6bf7232 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -808,13 +808,35 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None f"Sweeping funds from account {crypto_payment.account_index} subaddress {crypto_payment.subaddress_index} for payment {crypto_payment.id} to {crypto_payment.shop_sweep_to_address}" ) - # Check if account has enough unlocked balance for this payment + # Check for pending duplicate refunds that need this account's funds + pending_refund_amount_xmr = Decimal("0") + if dbsession: + pending_duplicates = ( + dbsession.query(CryptoPayment) + .filter( + CryptoPayment.coin_type == "XMR", + CryptoPayment.account_index == crypto_payment.account_index, + CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND, + CryptoPayment.refund_amount > 0, + ) + .all() + ) + + for dup in pending_duplicates: + dup_refund_xmr = Decimal(dup.refund_amount or 0) / atomic_units + pending_refund_amount_xmr += dup_refund_xmr + logger.info( + f"Reserving {dup_refund_xmr} XMR for pending duplicate refund {dup.id}" + ) + + # Check if account has enough unlocked balance for this payment and pending refunds payment_amount_piconero = crypto_payment.received_amount payment_amount_xmr = Decimal(payment_amount_piconero) / atomic_units + total_needed_xmr = payment_amount_xmr + pending_refund_amount_xmr - if unlocked_balance < payment_amount_xmr: + if unlocked_balance < total_needed_xmr: logger.info( - f"Account unlocked balance ({unlocked_balance} XMR) is less than payment amount ({payment_amount_xmr} XMR) - insufficient funds or funds still locked" + f"Account unlocked balance ({unlocked_balance} XMR) is less than payment amount ({payment_amount_xmr} XMR) plus pending refunds ({pending_refund_amount_xmr} XMR) - insufficient funds or funds still locked" ) return False @@ -883,13 +905,18 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None estimated_fee_piconero = int(Decimal("0.0001") * atomic_units) logger.warning(f"Failed to get dynamic fee estimate, using fallback: {e}") - transfer_amount_piconero = max( - 0, payment_amount_piconero - estimated_fee_piconero + # Calculate transfer amount: payment minus fee and reserve for pending refunds + reserved_for_refunds_piconero = int(pending_refund_amount_xmr * atomic_units) + available_for_sweep = ( + payment_amount_piconero + - estimated_fee_piconero + - reserved_for_refunds_piconero ) + transfer_amount_piconero = max(0, available_for_sweep) transfer_amount_xmr = Decimal(transfer_amount_piconero) / atomic_units logger.info( - f"Transferring {transfer_amount_xmr} XMR (payment amount minus {estimated_fee_piconero / atomic_units} XMR estimated fee) instead of sweeping" + f"Transferring {transfer_amount_xmr} XMR (payment amount minus {estimated_fee_piconero / atomic_units} XMR estimated fee and {pending_refund_amount_xmr} XMR reserved for refunds)" ) result = client._call( @@ -1004,11 +1031,34 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non f"Failed to get dynamic DOGE fee estimate, using fallback: {e}" ) - # Sweep balance minus estimated fee - sweep_amount = balance - Decimal(str(estimated_fee_doge)) + # Check for pending duplicate refunds that need this wallet's funds + pending_refund_amount = Decimal("0") + if dbsession: + pending_duplicates = ( + dbsession.query(CryptoPayment) + .filter( + CryptoPayment.coin_type == "DOGE", + CryptoPayment.address == crypto_payment.address, + CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND, + CryptoPayment.refund_amount > 0, + ) + .all() + ) + + for dup in pending_duplicates: + dup_refund_doge = Decimal(dup.refund_amount or 0) / atomic_units + pending_refund_amount += dup_refund_doge + logger.info( + f"Reserving {dup_refund_doge} DOGE for pending duplicate refund {dup.id}" + ) + + # Sweep balance minus estimated fee and pending refunds + sweep_amount = ( + balance - Decimal(str(estimated_fee_doge)) - pending_refund_amount + ) if sweep_amount <= 0: logger.info( - f"No funds to sweep after fee estimate ({estimated_fee_doge} DOGE) for payment {crypto_payment.id}" + f"No funds to sweep after fee estimate ({estimated_fee_doge} DOGE) and pending refunds ({pending_refund_amount} DOGE) for payment {crypto_payment.id}" ) return False @@ -1592,18 +1642,33 @@ def process_payment( f"DEBUG: Duplicate payment {crypto_payment.id} using stored received_amount: {crypto_payment.received_amount} atomic units = {received_crypto} {crypto_payment.coin_type}" ) - # Use the user from original payment (find by subaddress) - original_payment = ( - env_request.dbsession.query(CryptoPayment) - .filter( - CryptoPayment.coin_type == crypto_payment.coin_type, - CryptoPayment.account_index == crypto_payment.account_index, - CryptoPayment.subaddress_index == crypto_payment.subaddress_index, - CryptoPayment.id != crypto_payment.id, # Not this duplicate - CryptoPayment.received_amount > 0, # Has received payment + # Use the user from original payment (find by subaddress for XMR or address for DOGE) + if crypto_payment.coin_type == "XMR": + # For XMR, use account and subaddress indices + original_payment = ( + env_request.dbsession.query(CryptoPayment) + .filter( + CryptoPayment.coin_type == crypto_payment.coin_type, + CryptoPayment.account_index == crypto_payment.account_index, + CryptoPayment.subaddress_index + == crypto_payment.subaddress_index, + CryptoPayment.id != crypto_payment.id, # Not this duplicate + CryptoPayment.received_amount > 0, # Has received payment + ) + .first() + ) + else: + # For DOGE/BTC-like coins, use address + original_payment = ( + env_request.dbsession.query(CryptoPayment) + .filter( + CryptoPayment.coin_type == crypto_payment.coin_type, + CryptoPayment.address == crypto_payment.address, + CryptoPayment.id != crypto_payment.id, # Not this duplicate + CryptoPayment.received_amount > 0, # Has received payment + ) + .first() ) - .first() - ) if ( original_payment @@ -3443,7 +3508,27 @@ def run_once(env, interval): logger.error(f"Failed to get client for {coin_type}: {e}") continue - for crypto_payment in coin_payments: + # CRITICAL: Sort payments by priority - duplicate refunds FIRST to prevent wallet drain + # Priority order: + # 1. Duplicate refunds (need wallet funds) + # 2. Regular payments (might trigger auto-sweep which drains wallet) + def payment_priority(payment): + if payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND: + return 0 # Highest priority - process refunds first + elif payment.status in [ + CryptoPayment.STATUS_RECEIVED, + CryptoPayment.STATUS_CONFIRMED, + ]: + return 1 # Lower priority - might trigger auto-sweep + else: + return 2 # Lowest priority - other statuses + + sorted_payments = sorted(coin_payments, key=payment_priority) + logger.info( + f"Processing {coin_type} payments in priority order: duplicate refunds first" + ) + + for crypto_payment in sorted_payments: logger.info( f"Processing payment {crypto_payment.id} (status: {crypto_payment.status}, coin: {crypto_payment.coin_type}, incoming: {crypto_payment.current_confirmations}/{crypto_payment.confirmations_required}, refund: {crypto_payment.refund_confirmations if crypto_payment.refund_confirmations else 'N/A'})" ) diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index a14c1cb..0db08e1 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -216,9 +216,10 @@ def crypto_xmr_start(request): raise RuntimeError(f"Failed to fetch USD/XMR rate: {last_err}") # Compute piconero owed with dynamic transaction fee estimation - usd_total = float(invoice.total) - xmr_amount = usd_total / usd_per_xmr - base_amount_piconero = int(xmr_amount * 1_000_000_000_000) + usd_total = Decimal(str(invoice.total)) + xmr_amount = usd_total / Decimal(str(usd_per_xmr)) + # For fee estimation - use ceiling to avoid underestimating fees + base_amount_piconero = math.ceil(xmr_amount * Decimal("1000000000000")) # Get dynamic fee estimate - we need the processor config first from ..models.crypto_processor import CryptoProcessor @@ -242,9 +243,10 @@ def crypto_xmr_start(request): # Fallback to hardcoded fee if no processor configured yet fee_buffer_xmr = Decimal(settings.get("monero.fee_buffer", "0.0001")) - xmr_amount_with_fee = Decimal(str(xmr_amount)) + fee_buffer_xmr + xmr_amount_with_fee = xmr_amount + fee_buffer_xmr + # Stay in Decimal arithmetic to avoid floating point precision issues expected_piconero = math.ceil( - xmr_amount_with_fee * 1_000_000_000_000 + xmr_amount_with_fee * Decimal("1000000000000") ) # Round UP for quotes # Quote expiry - use shop-specific setting @@ -305,7 +307,7 @@ def crypto_xmr_start(request): # Convert fee to atomic units (piconero) for storage estimated_fee_piconero = math.ceil( - fee_buffer_xmr * 1_000_000_000_000 + fee_buffer_xmr * Decimal("1000000000000") ) # Round UP for fees # Persist CryptoPayment @@ -503,14 +505,15 @@ def crypto_doge_start(request): raise RuntimeError(f"Failed to fetch USD/DOGE rate: {last_err}") # Compute koinu (smallest unit) owed with dynamic transaction fee estimation - usd_total = float(invoice.total) - doge_amount = usd_total / usd_per_doge + usd_total = Decimal(str(invoice.total)) + doge_amount = usd_total / Decimal(str(usd_per_doge)) # Use dynamic fee estimation for more accurate quotes fee_buffer_doge = estimate_dogecoin_fee_for_quote(settings) - doge_amount_with_fee = Decimal(str(doge_amount)) + fee_buffer_doge + doge_amount_with_fee = doge_amount + fee_buffer_doge + # Stay in Decimal arithmetic to avoid floating point precision issues expected_koinu = math.ceil( - doge_amount_with_fee * 100_000_000 + doge_amount_with_fee * Decimal("100000000") ) # Round UP for quotes # Quote expiry - use shop-specific setting @@ -589,7 +592,7 @@ def crypto_doge_start(request): # Convert fee to atomic units (koinu) for storage estimated_fee_koinu = math.ceil( - fee_buffer_doge * 100_000_000 + fee_buffer_doge * Decimal("100000000") ) # Round UP for fees # Persist CryptoPayment (using account_index=0, subaddress_index=0 for Bitcoin-like coins)