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.
This commit is contained in:
Russell Ballestrini 2025-10-02 19:04:13 -04:00
parent dc092d920d
commit a74bf72e6a
5 changed files with 252 additions and 51 deletions

View file

@ -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)

View file

@ -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}")

View file

@ -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(

View file

@ -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')

View file

@ -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}"