From a74bf72e6a04f9ce76a0014bbf1367d7f1f40e95 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 19:04:13 -0400 Subject: [PATCH] Add sweep transaction confirmation monitoring - Added global OUTBOUND_CONFIRMATIONS_REQUIRED config (2 for DOGE, 10 for XMR) - Added swept_confirmations field to CryptoPayment model - Modified auto-sweep functions to track confirmations instead of immediately transitioning to confirmed-complete - Added process_sweep_confirmations() to monitor sweep transactions - Standardized confirmation requirements for both sweeps and refunds - Created alembic migration 0915b3ff883d for swept_confirmations field This ensures we track when funds actually leave the hot wallet and provides consistent monitoring for all outbound transactions. --- make_post_sell/lib/crypto_watcher/__init__.py | 139 +++++++++++++++++- .../crypto_watcher/crypto_payment_rescue.py | 120 +++++++++------ make_post_sell/models/crypto_payment.py | 3 + ...add_swept_confirmations_to_track_sweep_.py | 30 ++++ make_post_sell/views/cart.py | 11 +- 5 files changed, 252 insertions(+), 51 deletions(-) create mode 100644 make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 1890efd..fe6bd32 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -28,6 +28,13 @@ from ...models.user_crypto_refund_address import UserCryptoRefundAddress logger = logging.getLogger(__name__) +# Global confirmation requirements for outbound transactions (sweeps and refunds) +# These apply AFTER the payment is already confirmed incoming +OUTBOUND_CONFIRMATIONS_REQUIRED = { + "XMR": 10, # Monero: 10 confirmations for both sweeps and refunds + "DOGE": 2, # Dogecoin: 2 confirmations for both sweeps and refunds +} + class CryptoWatcherLogger: """Centralized logging helper for crypto watcher operations.""" @@ -948,8 +955,9 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None crypto_payment.swept_tx_hash = tx_hash crypto_payment.swept_timestamp = now_timestamp() crypto_payment.swept_network_fee = fee - # Update status to confirmed-complete after successful sweep - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + crypto_payment.swept_confirmations = 0 # Start tracking confirmations + # Status stays as CONFIRMED until sweep is confirmed + # crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE if dbsession: dbsession.add(crypto_payment) log.sweep_operation( @@ -1101,8 +1109,9 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non crypto_payment.swept_timestamp = now_timestamp() # Note: DOGE RPC doesn't return fee info easily, so we leave it None crypto_payment.swept_network_fee = None - # Update status to confirmed-complete after successful sweep - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + crypto_payment.swept_confirmations = 0 # Start tracking confirmations + # Status stays as CONFIRMED until sweep is confirmed + # crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE if dbsession: dbsession.add(crypto_payment) @@ -3076,8 +3085,12 @@ def process_refund_confirmations(request, settings): if confirmations != old_confirmations: log.confirmation_update(payment, old_confirmations, confirmations) - # Check if refund is now fully confirmed (10+ confirmations) - if confirmations >= 10: + # Check if refund is now fully confirmed based on coin type requirements + required_confirmations = OUTBOUND_CONFIRMATIONS_REQUIRED.get( + coin_type, 10 + ) + + if confirmations >= required_confirmations: old_status = payment.status # Transition to final refunded status @@ -3335,6 +3348,117 @@ def update_payment_confirmations_only(client, crypto_payment, coin_type): ) +def process_sweep_confirmations(request, settings): + """ + Monitor sweep transactions for confirmation status. + Updates swept_confirmations and transitions status when fully confirmed. + """ + log.processing_cycle("Starting sweep confirmation monitoring") + db = request.dbsession + + # Query payments that need sweep confirmation monitoring + sweep_queue = ( + db.query(CryptoPayment) + .options( + sa.orm.joinedload(CryptoPayment.user), + sa.orm.joinedload(CryptoPayment.shop), + ) + .filter( + CryptoPayment.status == CryptoPayment.STATUS_CONFIRMED, + CryptoPayment.swept_tx_hash != None, + CryptoPayment.swept_confirmations < 10, # Max threshold + ) + .all() + ) + + if not sweep_queue: + log.processing_cycle("No sweep transactions need confirmation monitoring") + return + + log.processing_cycle("Found sweep transactions to monitor", len(sweep_queue)) + + # Group by coin type to get appropriate clients + sweeps_by_coin = {} + for payment in sweep_queue: + coin_type = payment.coin_type + if coin_type not in sweeps_by_coin: + sweeps_by_coin[coin_type] = [] + sweeps_by_coin[coin_type].append(payment) + + # Process each coin type + for coin_type, coin_sweeps in sweeps_by_coin.items(): + log.processing_cycle( + f"Monitoring {coin_type} sweep transactions", len(coin_sweeps) + ) + + try: + client = get_crypto_client(settings, coin_type) + except ValueError as e: + log.error_with_context( + f"Failed to get {coin_type} client for sweep monitoring", e + ) + continue + + for payment in coin_sweeps: + try: + # Get confirmation count for the sweep transaction + confirmations = 0 + if coin_type == "XMR": + confirmations = get_monero_tx_confirmations( + client, payment.swept_tx_hash + ) + elif coin_type == "DOGE": + confirmations = get_dogecoin_tx_confirmations( + client, payment.swept_tx_hash + ) + else: + log.payment_error( + payment, + f"Unsupported coin type for sweep monitoring: {coin_type}", + ) + continue + + # Update confirmation count + old_confirmations = payment.swept_confirmations + payment.swept_confirmations = confirmations + + if confirmations != old_confirmations: + log.payment_info( + payment, + f"Sweep confirmations: {old_confirmations} → {confirmations}", + ) + + # Check if sweep is now fully confirmed based on coin type requirements + required_confirmations = OUTBOUND_CONFIRMATIONS_REQUIRED.get( + coin_type, 10 + ) + + if confirmations >= required_confirmations: + old_status = payment.status + # Transition to confirmed-complete + payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + log.state_transition( + payment, + old_status, + payment.status, + f"sweep confirmed with {confirmations} confirmations", + ) + log.payment_info( + payment, + f"Sweep fully confirmed - funds have left hot wallet", + ) + + # Update timestamp + payment.updated_timestamp = int(time.time() * 1000) + db.add(payment) + + except Exception as e: + log.payment_error(payment, "Failed to check sweep confirmations", e) + continue + + log.processing_cycle("Finished sweep confirmation monitoring") + + def scan_wallet_for_double_or_late_payments(request, settings): """ Scan wallet for new incoming transactions since last scan position and match them to payments. @@ -4060,6 +4184,9 @@ def run_once(env, interval): # Process refund confirmation monitoring process_refund_confirmations(request, settings) + # Process sweep confirmation monitoring + process_sweep_confirmations(request, settings) + def main(argv=sys.argv): args = parse_args(argv) diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index e2e4c6c..bc60a63 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -164,7 +164,7 @@ class PaymentRescue: def execute_refund(self, refund_details, payment=None): """ Execute the actual refund transaction. - + Now implements multi-output transactions: - Customer gets refund minus fee - Shop owner gets the fee portion @@ -247,9 +247,9 @@ class PaymentRescue: # Monero always uses multi-output transfer if not shop_sweep_address: raise ValueError("Shop sweep address is required for refunds") - + account_index = payment.account_index if payment else 0 - + # Build destinations array destinations = [ { @@ -257,18 +257,19 @@ class PaymentRescue: "amount": refund_amount_atomic, } ] - + # For overpayments, combine payment amount and fee into single shop output shop_amount_atomic = fee_amount_atomic if payment_amount_atomic > 0: shop_amount_atomic += payment_amount_atomic - - destinations.append({ - "address": shop_sweep_address, - "amount": shop_amount_atomic, - }) - - + + destinations.append( + { + "address": shop_sweep_address, + "amount": shop_amount_atomic, + } + ) + transfer_params = { "destinations": destinations, "account_index": account_index, @@ -282,91 +283,128 @@ class PaymentRescue: # Dogecoin always uses sendmany for multi-output if not shop_sweep_address: raise ValueError("Shop sweep address is required for refunds") - + # Round to 8 decimal places to match DOGE precision requirements refund_amount_doge = round(float(refund_amount_coin), 8) fee_amount_doge = round(float(fee_amount_coin), 8) - payment_amount_doge = round(float(payment_amount_coin), 8) if payment_amount_coin > 0 else 0 - + payment_amount_doge = ( + round(float(payment_amount_coin), 8) + if payment_amount_coin > 0 + else 0 + ) + # Initial outputs - will be adjusted below if needed outputs = {} - + # For overpayments, combine payment amount and fee into single shop output shop_amount_doge = fee_amount_doge if payment_amount_doge > 0: shop_amount_doge = round(payment_amount_doge + fee_amount_doge, 8) - + # Account for network fee by reducing amounts proportionally # Dogecoin sendmany adds fee on top of outputs, so we need to leave room total_output = refund_amount_doge + shop_amount_doge - + # Fee estimate - can be overridden via environment variable import os - estimated_fee = float(os.environ.get('DOGE_REFUND_FEE_BUFFER', str(DOGE_REFUND_FEE_BUFFER))) + + estimated_fee = float( + os.environ.get( + "DOGE_REFUND_FEE_BUFFER", str(DOGE_REFUND_FEE_BUFFER) + ) + ) if estimated_fee != DOGE_REFUND_FEE_BUFFER: - logger.info(f"Using custom fee buffer from env: {estimated_fee} DOGE") - + logger.info( + f"Using custom fee buffer from env: {estimated_fee} DOGE" + ) + # Check if we need to adjust for fees if balance_result and total_output + estimated_fee > balance_result: # Calculate how much we need to reduce shortage = (total_output + estimated_fee) - balance_result - + # Reduce both amounts proportionally refund_ratio = refund_amount_doge / total_output shop_ratio = shop_amount_doge / total_output - + # Round down to 3 decimal places import math - refund_amount_doge = math.floor((refund_amount_doge - shortage * refund_ratio) * 1000) / 1000 - shop_amount_doge = math.floor((shop_amount_doge - shortage * shop_ratio) * 1000) / 1000 - + + refund_amount_doge = ( + math.floor( + (refund_amount_doge - shortage * refund_ratio) * 1000 + ) + / 1000 + ) + shop_amount_doge = ( + math.floor((shop_amount_doge - shortage * shop_ratio) * 1000) + / 1000 + ) + # Build final outputs with adjusted amounts outputs[refund_details["refund_address"]] = refund_amount_doge outputs[shop_sweep_address] = shop_amount_doge - - + # Get the account for the payment address from_account = "" try: - from_account = self.crypto_client._call("getaccount", [payment.address]) + from_account = self.crypto_client._call( + "getaccount", [payment.address] + ) except Exception: pass - + # Clean outputs - ensure plain floats - clean_outputs = {addr: float(amount) for addr, amount in outputs.items()} - + clean_outputs = { + addr: float(amount) for addr, amount in outputs.items() + } + # Try sendmany with the account that has the funds try: - tx_hash = self.crypto_client.sendmany(from_account, clean_outputs, 1) + tx_hash = self.crypto_client.sendmany( + from_account, clean_outputs, 1 + ) except Exception as e: error_msg = str(e).lower() if "insufficient funds" in error_msg: # Log detailed fee information when we hit insufficient funds logger.error(f"INSUFFICIENT FUNDS - Fee estimate too low!") logger.error(f"Current fee buffer: {estimated_fee} DOGE") - logger.error(f"Total outputs: {sum(clean_outputs.values())} DOGE") + logger.error( + f"Total outputs: {sum(clean_outputs.values())} DOGE" + ) logger.error(f"Available balance: {balance_result} DOGE") - shortfall = sum(clean_outputs.values()) + estimated_fee - balance_result - logger.error(f"Shortfall: {shortfall:.8f} DOGE (may need more for actual network fee)") - logger.error(f"ACTION REQUIRED: Increase DOGE_REFUND_FEE_BUFFER constant at top of crypto_payment_rescue.py") - + shortfall = ( + sum(clean_outputs.values()) + estimated_fee - balance_result + ) + logger.error( + f"Shortfall: {shortfall:.8f} DOGE (may need more for actual network fee)" + ) + logger.error( + f"ACTION REQUIRED: Increase DOGE_REFUND_FEE_BUFFER constant at top of crypto_payment_rescue.py" + ) + # Try with default account as fallback try: tx_hash = self.crypto_client.sendmany("", clean_outputs, 1) except Exception as e2: if "insufficient funds" in str(e2).lower(): - logger.error(f"Both accounts failed - fee definitely too low!") + logger.error( + f"Both accounts failed - fee definitely too low!" + ) raise e2 else: raise - + tx_result = {"tx_hash": tx_hash} - + # Log success with fee info for monitoring logger.info(f"Refund sent successfully! TX: {tx_hash}") if balance_result: buffer_used = balance_result - sum(clean_outputs.values()) - logger.info(f"Fee buffer used: {buffer_used:.8f} DOGE (estimated: {estimated_fee})") + logger.info( + f"Fee buffer used: {buffer_used:.8f} DOGE (estimated: {estimated_fee})" + ) else: raise ValueError(f"Refund not supported for coin type: {coin_type}") diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index b7905bd..b869f8f 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -219,6 +219,9 @@ class CryptoPayment(RBase, Base): swept_network_fee = Column( BigInteger, nullable=True ) # Network fee paid (in atomic units, from node) + swept_confirmations = Column( + Integer, nullable=False, default=0 + ) # Current confirmation count of sweep transaction # Current confirmation count current_confirmations = Column( diff --git a/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py new file mode 100644 index 0000000..02c075f --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py @@ -0,0 +1,30 @@ +"""Add swept_confirmations to track sweep transaction confirmations + +Revision ID: 0915b3ff883d +Revises: 07908c8c840d +Create Date: 2025-10-02 19:00:08.788508 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0915b3ff883d' +down_revision = '07908c8c840d' +branch_labels = None +depends_on = None + +from make_post_sell.models.meta import UUIDType + + +def upgrade(): + # Add swept_confirmations column to mps_crypto_payment table + op.add_column('mps_crypto_payment', + sa.Column('swept_confirmations', sa.Integer(), nullable=False, server_default='0') + ) + + +def downgrade(): + # Remove swept_confirmations column from mps_crypto_payment table + op.drop_column('mps_crypto_payment', 'swept_confirmations') diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index 06ae3a9..7207da6 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -49,14 +49,17 @@ def get_smart_purchase_redirect_url(invoices): - General purchases page as fallback """ import logging + logger = logging.getLogger(__name__) - + # Handle single invoice with single digital product if len(invoices) == 1: invoice = invoices[0] line_items = list(invoice.line_items) - - logger.info(f"Smart redirect check - Invoice {invoice.id} has {len(line_items)} line items") + + logger.info( + f"Smart redirect check - Invoice {invoice.id} has {len(line_items)} line items" + ) # Single digital product - redirect to download page if len(line_items) == 1: @@ -65,7 +68,7 @@ def get_smart_purchase_redirect_url(invoices): logger.info(f"Is physical: {product.is_physical}") logger.info(f"Has product file: {product.has_product_file}") logger.info(f"Product extensions: {product.extensions}") - + # Check if it's a digital product with a file if not product.is_physical and product.has_product_file: redirect_url = f"/p/{product.id}/{product.slug}"