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:
parent
dc092d920d
commit
a74bf72e6a
5 changed files with 252 additions and 51 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue