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:
parent
9211ba7697
commit
523599c108
2 changed files with 120 additions and 32 deletions
|
|
@ -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'})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue