Fix crypto payment precision and order of operations issues

- Fix precision mismatches by using Decimal arithmetic throughout quote generation
- Replace int() truncation with math.ceil() for atomic unit conversions
- Update DOGE and XMR quote generation to avoid floating point errors
- Fix order of operations: process duplicate refunds BEFORE auto-sweep
- Add fund reservation logic to auto-sweep to protect pending refunds
- Fix duplicate payment queries to use address for DOGE, subaddress for XMR

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Russell Ballestrini 2025-09-29 16:45:33 -04:00
parent 9211ba7697
commit 523599c108
2 changed files with 120 additions and 32 deletions

View file

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

View file

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