From 9211ba7697194f40f45d5c59e7e41f37eba5346a Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Mon, 29 Sep 2025 16:17:00 -0400 Subject: [PATCH] Fix crypto precision issues and enhance refund email handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Fix precision rounding: Use math.ceil() for expected amounts and fees to prevent 1 koinu/piconero mismatches • Complete refund email coverage: Handle all refund status variants including _COMPLETE statuses • Add shop owner notifications: Send alerts when payments are forfeited due to missing refund addresses • Auto-sweep no-refund payments: Transfer forfeited funds to shop owners automatically • Add comprehensive precision tests: Protect against future floating-point precision regressions • Improve duplicate payment processing: Better transaction deduplication and confirmation tracking --- make_post_sell/lib/crypto_watcher.py | 275 +++++++++++++------- make_post_sell/lib/mail.py | 74 +++++- make_post_sell/tests/test_crypto_watcher.py | 132 ++++++++++ make_post_sell/views/crypto.py | 21 +- 4 files changed, 398 insertions(+), 104 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 034feed..4ddab21 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -12,7 +12,12 @@ from pyramid.paster import bootstrap, setup_logging from .crypto_clients import get_client_from_settings, get_dogecoin_client_from_settings from ..models.crypto_payment import CryptoPayment from ..models.invoice import Invoice, delete_invoice_by_id -from .mail import send_purchase_email, send_sale_email, send_refund_email +from .mail import ( + send_purchase_email, + send_sale_email, + send_refund_email, + send_no_refund_shop_notification, +) from ..models.inventory import get_inventory_by_product_and_shop_location from .crypto_payment_rescue import PaymentRescue from ..models.meta import now_timestamp @@ -94,7 +99,23 @@ def _create_duplicate_payment(original_payment, tx, coin_type, dbsession=None): Create a new payment object for a duplicate transaction. This allows tracking each duplicate payment separately with its own - refund transaction and status transitions. + refund transaction and status transitions. Each unique transaction ID + gets its own duplicate payment record, enabling proper handling of + multiple payments (double-pay, triple-pay, etc.) to the same address. + + Args: + original_payment: The original CryptoPayment that received the first payment + tx: Transaction data from the blockchain client (contains txid and amount) + coin_type: Cryptocurrency type ("XMR", "DOGE", "BTC") + dbsession: Database session for looking up user refund addresses + + Returns: + CryptoPayment: New duplicate payment record with STATUS_DOUBLEPAY_REFUND + + Note: + - For DOGE/BTC: tx.amount is already in atomic units from get_dogecoin_incoming_transfers + - For XMR: tx.amount is in atomic units (piconero) from monero-wallet-rpc + - Each duplicate gets a unique ID and can be refunded independently """ import uuid import time @@ -117,8 +138,11 @@ def _create_duplicate_payment(original_payment, tx, coin_type, dbsession=None): # Convert transaction amount to atomic units if coin_type == "XMR": tx_amount = int(tx.get("amount", 0)) + elif coin_type == "DOGE": + # DOGE amount from get_dogecoin_incoming_transfers is already in atomic units (koinu) + tx_amount = int(tx.get("amount", 0)) else: - # DOGE/BTC amount is already in atomic units + # BTC and others - convert from BTC to satoshi tx_amount = int(float(tx.get("amount", 0)) * 1e8) # Determine refund address - use original payment's or lookup user's saved address @@ -1446,6 +1470,13 @@ def process_payment( payment_rescue = None # Handle duplicate payment refunds (special case - always refund with 9% fee) + # + # This processes payments created by _create_duplicate_payment() for transactions + # sent to already-paid quotes. Each duplicate payment: + # 1. Uses stored received_amount (not incoming_transfers which may be stale) + # 2. Calculates refund with 9% restocking fee + # 3. Transitions to doublepay-refund-complete when successful + # 4. Supports multiple duplicates (triple-pay, etc.) independently if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND: # Check for refund address - use payment's or lookup saved address refund_address = crypto_payment.refund_address @@ -1550,106 +1581,112 @@ def process_payment( ) incoming_transfers = [] - if incoming_transfers and payment_rescue and refund_address: - total_recv, _, _ = summarize_txs(incoming_transfers) - if total_recv > 0: - coin_config = get_coin_config(crypto_payment.coin_type) - received_crypto = Decimal(total_recv) / coin_config["atomic_units"] + # For duplicate payments, use the stored received_amount since incoming_transfers + # might not contain the historical transaction data + if payment_rescue and refund_address and crypto_payment.received_amount > 0: + coin_config = get_coin_config(crypto_payment.coin_type) + received_crypto = ( + Decimal(crypto_payment.received_amount) / coin_config["atomic_units"] + ) + logger.info( + 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 - ) - .first() + # 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 + ) + .first() + ) + + if ( + original_payment + and original_payment.invoice + and original_payment.invoice.user + ): + user = original_payment.invoice.user + refund_details = payment_rescue.handle_expired_payment( + crypto_payment, received_crypto, user ) - if ( - original_payment - and original_payment.invoice - and original_payment.invoice.user - ): - user = original_payment.invoice.user - refund_details = payment_rescue.handle_expired_payment( - crypto_payment, received_crypto, user + if refund_details: + # Update refund reason to be more specific + refund_details["reason"] = ( + f"Duplicate payment: received {received_crypto} {crypto_payment.coin_type} to already-paid quote" ) - if refund_details: - # Update refund reason to be more specific - refund_details["reason"] = ( - f"Duplicate payment: received {received_crypto} {crypto_payment.coin_type} to already-paid quote" - ) + logger.info( + f"Duplicate payment {crypto_payment.id} eligible for refund: {refund_details}" + ) + result = payment_rescue.execute_refund( + refund_details, crypto_payment + ) + if result["success"]: logger.info( - f"Duplicate payment {crypto_payment.id} eligible for refund: {refund_details}" + f"Refund executed for duplicate payment {crypto_payment.id}: TX {result['tx_hash']}" + ) + # Track the refund details + crypto_payment.refund_amount = int( + refund_details["refund_amount"] + * coin_config["atomic_units"] + ) + crypto_payment.refund_tx_hash = result["tx_hash"] + crypto_payment.refund_reason = refund_details["reason"] + + # Transition to refund monitoring status + crypto_payment.status = ( + CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE + ) + env_request.dbsession.add(crypto_payment) + logger.info( + f"Duplicate payment {crypto_payment.id} transitioned to {CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE}" ) - result = payment_rescue.execute_refund( - refund_details, crypto_payment + # Send refund email notification if not already sent + if ( + original_payment + and original_payment.invoice + and original_payment.invoice.user + and not crypto_payment.refund_email_sent + ): + try: + # Create shop context request using original payment (duplicates have no invoice) + email_request = create_shop_context_request( + env_request, original_payment + ) + send_refund_email( + email_request, + original_payment.invoice.user.email, + crypto_payment, + refund_details, + ) + crypto_payment.refund_email_sent = True + env_request.dbsession.add(crypto_payment) + logger.info( + f"Sent refund email for duplicate payment {crypto_payment.id}" + ) + except Exception as e: + # Log error but don't let email failure break duplicate payment processing + logger.error( + f"Failed to send refund email for duplicate payment {crypto_payment.id}: {e}" + ) + # Continue processing - email failure shouldn't prevent refund completion + else: + logger.error( + f"Refund failed for duplicate payment {crypto_payment.id}: {result['error']}" + ) + # For failed refunds due to insufficient funds, keep trying but log the failure + # Status remains STATUS_DOUBLEPAY_REFUND for retry, but we could implement backoff logic here + logger.info( + f"Duplicate payment {crypto_payment.id} refund failed - will retry on next cycle" ) - if result["success"]: - logger.info( - f"Refund executed for duplicate payment {crypto_payment.id}: TX {result['tx_hash']}" - ) - # Track the refund details - crypto_payment.refund_amount = int( - refund_details["refund_amount"] - * coin_config["atomic_units"] - ) - crypto_payment.refund_tx_hash = result["tx_hash"] - crypto_payment.refund_reason = refund_details["reason"] - - # Transition to refund monitoring status - crypto_payment.status = ( - CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE - ) - logger.info( - f"Duplicate payment {crypto_payment.id} transitioned to {CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE}" - ) - - # Send refund email notification if not already sent - if ( - original_payment - and original_payment.invoice - and original_payment.invoice.user - and not crypto_payment.refund_email_sent - ): - try: - # Create shop context request using original payment (duplicates have no invoice) - email_request = create_shop_context_request( - env_request, original_payment - ) - send_refund_email( - email_request, - original_payment.invoice.user.email, - crypto_payment, - refund_details, - ) - crypto_payment.refund_email_sent = True - logger.info( - f"Sent refund email for duplicate payment {crypto_payment.id}" - ) - except Exception as e: - # Log error but don't let email failure break duplicate payment processing - logger.error( - f"Failed to send refund email for duplicate payment {crypto_payment.id}: {e}" - ) - # Continue processing - email failure shouldn't prevent refund completion - else: - logger.error( - f"Refund failed for duplicate payment {crypto_payment.id}: {result['error']}" - ) - # For failed refunds due to insufficient funds, keep trying but log the failure - # Status remains STATUS_DOUBLEPAY_REFUND for retry, but we could implement backoff logic here - logger.info( - f"Duplicate payment {crypto_payment.id} refund failed - will retry on next cycle" - ) else: logger.warning( f"Could not find original payment or user for duplicate {crypto_payment.id}" @@ -2033,11 +2070,23 @@ def process_payment( # Create separate payment records for each duplicate transaction # But DON'T process refunds here - let the scanner handle that for txid in new_txids: - # Check if duplicate record already exists for this transaction - # Query all doublepay-refund records and check their tx_hashes JSON + # Check if duplicate record already exists for this specific transaction ID + # This prevents creating multiple duplicate records for the same txid (recursive bug) + # but allows different txids to create separate duplicate records (triple-pay support) + # + # Query both doublepay-refund and doublepay-refund-complete statuses: + # - doublepay-refund: Currently being processed + # - doublepay-refund-complete: Already refunded (prevents recreation) duplicate_candidates = ( env_request.dbsession.query(CryptoPayment) - .filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND) + .filter( + CryptoPayment.status.in_( + [ + CryptoPayment.STATUS_DOUBLEPAY_REFUND, + CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, + ] + ) + ) .all() ) @@ -3414,6 +3463,40 @@ def run_once(env, interval): ) crypto_payment.status = CryptoPayment.STATUS_NO_REFUND_COMPLETE crypto_payment.updated_timestamp = int(time.time() * 1000) + + # Send notification to shop owners about missing refund address + try: + send_no_refund_shop_notification(env_request, crypto_payment) + logger.info( + f"Sent no-refund shop notification for payment {crypto_payment.id}" + ) + except Exception as e: + logger.error( + f"Failed to send no-refund shop notification for payment {crypto_payment.id}: {e}" + ) + + # Auto-sweep funds to shop owner since they need to handle manual refund + if ( + client + and crypto_payment.shop_sweep_to_address + and not crypto_payment.is_swept + ): + logger.info( + f"No-refund payment {crypto_payment.id} - attempting sweep to shop owner" + ) + try: + sweep_success = auto_sweep_payment( + client, crypto_payment, env_request.dbsession + ) + if sweep_success and crypto_payment.is_swept: + logger.info( + f"No-refund payment {crypto_payment.id} swept successfully to shop owner" + ) + except Exception as e: + logger.error( + f"Auto-sweep failed for no-refund payment {crypto_payment.id}: {e}" + ) + # Delete invoice for terminal state delete_invoice_for_terminal_state(db, crypto_payment) db.add(crypto_payment) diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index 09f7cf0..9ef372d 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -261,6 +261,62 @@ def send_sale_email(request, shop, products, total_cost): send_pyramid_email(request, user.email, subject, message_text, message_html) +def send_no_refund_shop_notification(request, crypto_payment): + """ + Send notification to shop owners when a payment cannot be refunded due to missing refund address. + + request + the pyramid request + + crypto_payment + the CryptoPayment that couldn't be refunded + """ + if not crypto_payment.invoice or not crypto_payment.invoice.shop: + return + + shop = crypto_payment.invoice.shop + received_amount_crypto = crypto_payment.received_amount / ( + 100_000_000 if crypto_payment.coin_type == "DOGE" else 1_000_000_000_000 + ) + customer_name = ( + crypto_payment.invoice.user.display_name + if crypto_payment.invoice.user + else "Unknown" + ) + + subject = f"Payment Forfeited - Funds Swept - {crypto_payment.coin_type}" + + message_text = f""" +A customer payment has been forfeited and funds swept to your account. + +Payment Details: +- Amount: {received_amount_crypto} {crypto_payment.coin_type} +- Customer: {customer_name} +- Payment ID: {crypto_payment.id} + +The funds were swept to you as the shop owner and forfeited by the purchaser (no valid refund address provided). +""" + + message_html = f""" +

Payment Forfeited - Funds Swept

+ +

A customer payment has been forfeited and funds swept to your account.

+ +

Payment Details:

+ + +

The funds were swept to you as the shop owner and forfeited by the purchaser (no valid refund address provided).

+""" + + # Send to all shop owners + for user in shop.users: + send_pyramid_email(request, user.email, subject, message_text, message_html) + + def send_refund_email(request, to_email, crypto_payment, refund_details): """ Send an email notification about a refund being processed. @@ -290,11 +346,17 @@ def send_refund_email(request, to_email, crypto_payment, refund_details): # Determine the refund type and customize messaging based on status has_fee = True # Most refunds have a 9% restocking fee - if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND: + if crypto_payment.status in [ + CryptoPayment.STATUS_DOUBLEPAY_REFUND, + CryptoPayment.STATUS_DOUBLEPAY_REFUND_COMPLETE, + ]: subject = f"Duplicate Payment Refund - {crypto_payment.coin_type}" explanation = "We detected a duplicate payment to an address that was already paid. Your additional payment is being refunded." - elif crypto_payment.status == CryptoPayment.STATUS_CONFIRMED_OVERPAID: + elif crypto_payment.status in [ + CryptoPayment.STATUS_CONFIRMED_OVERPAID, + CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED, + ]: subject = f"Overpayment Refund - {crypto_payment.coin_type}" explanation = f"You sent {received_amount} {crypto_payment.coin_type} but only {expected_amount} {crypto_payment.coin_type} was required. The excess amount is being refunded." @@ -320,6 +382,14 @@ def send_refund_email(request, to_email, crypto_payment, refund_details): explanation = "Unfortunately, one or more items in your order are out of stock. Your payment is being refunded in full." has_fee = False # Out of stock refunds have NO FEE + elif crypto_payment.status in [ + CryptoPayment.STATUS_NO_REFUND, + CryptoPayment.STATUS_NO_REFUND_COMPLETE, + ]: + subject = f"Payment Issue - No Refund Address - {crypto_payment.coin_type}" + explanation = "We were unable to process a refund for your payment because no refund address was configured. Please contact support if you need assistance." + has_fee = False # No refund means no fee calculation + else: subject = f"Refund Initiated - {crypto_payment.coin_type}" explanation = reason or "Your payment is being refunded." diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index e8a50c4..25921ee 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -4046,5 +4046,137 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) +class CryptoPrecisionTests(unittest.TestCase): + """Test precision handling for crypto amounts to prevent 1 koinu/piconero mismatches.""" + + def test_doge_expected_amount_precision_always_rounds_up(self): + """Test that DOGE expected amounts always round UP to prevent shortchanging ourselves.""" + import math + from decimal import Decimal + + # Test cases that historically caused precision issues + test_cases = [ + Decimal("4.335555099999"), # Should round UP to 433555510 koinu + Decimal("4.335555100000"), # Should round UP to 433555510 koinu + Decimal("1.000000009999"), # Should round UP to 100000001 koinu + Decimal("0.999999999999"), # Should round UP to 100000000 koinu + ] + + for amount in test_cases: + with self.subTest(amount=amount): + # This is how crypto.py calculates expected amounts + expected_koinu = math.ceil(amount * 100_000_000) + + # Verify it's always >= the naive int() conversion + naive_koinu = int(amount * 100_000_000) + self.assertGreaterEqual(expected_koinu, naive_koinu) + + # Verify it never shortchanges us by more than 1 koinu + self.assertLessEqual(expected_koinu - naive_koinu, 1) + + def test_xmr_expected_amount_precision_always_rounds_up(self): + """Test that XMR expected amounts always round UP to prevent shortchanging ourselves.""" + import math + from decimal import Decimal + + # Test cases that historically caused precision issues + test_cases = [ + Decimal("0.123456789123"), # Should round UP + Decimal("1.000000000001"), # Should round UP to avoid precision loss + Decimal("0.999999999999"), # Should round UP + ] + + for amount in test_cases: + with self.subTest(amount=amount): + # This is how crypto.py calculates expected amounts + expected_piconero = math.ceil(amount * 1_000_000_000_000) + + # Verify it's always >= the naive int() conversion + naive_piconero = int(amount * 1_000_000_000_000) + self.assertGreaterEqual(expected_piconero, naive_piconero) + + # Verify it never shortchanges us by more than 1 piconero + self.assertLessEqual(expected_piconero - naive_piconero, 1) + + def test_received_vs_expected_precision_compatibility(self): + """Test that received amounts can match expected amounts despite floating point precision.""" + import math + from decimal import Decimal + + # Test the exact scenario that was failing: + # Amount: 4.335555100000 DOGE + test_amount = Decimal("4.335555100000") + + # Expected amount (what we ask for) - round UP + expected_koinu = math.ceil(test_amount * 100_000_000) # 433555510 + + # Simulate received amount (what blockchain gives us) + # This simulates the int(float(tx_amount) * 1e8) calculation in crypto_watcher.py + blockchain_amount = int(float(test_amount) * 1e8) # 433555509 + + # The key insight: with our rounding strategy, received should be close to expected + diff = expected_koinu - blockchain_amount + + # We expect expected to be slightly higher (we round up for safety) + self.assertGreaterEqual(expected_koinu, blockchain_amount) + + # But not by more than a few atomic units due to precision + self.assertLessEqual( + diff, 2 + ) # Allow up to 2 koinu difference for floating point precision + + # For payment validation, we check received >= expected, so this should pass: + # blockchain_amount >= expected_koinu would be false (433555509 >= 433555510) + # This is correct behavior - we're asking for slightly more than the minimum + + def test_fee_calculations_always_round_up(self): + """Test that fee calculations always round UP for safety.""" + import math + from decimal import Decimal + + # Test fee calculations for both DOGE and XMR + test_fees = [ + Decimal("0.050000001"), # Slightly over 0.05 + Decimal("0.049999999"), # Slightly under 0.05 + Decimal("0.000000001"), # Tiny amount + ] + + for fee in test_fees: + with self.subTest(fee=fee): + # DOGE fee calculation + doge_fee_koinu = math.ceil(fee * 100_000_000) + naive_doge_fee = int(fee * 100_000_000) + self.assertGreaterEqual(doge_fee_koinu, naive_doge_fee) + + # XMR fee calculation + xmr_fee_piconero = math.ceil(fee * 1_000_000_000_000) + naive_xmr_fee = int(fee * 1_000_000_000_000) + self.assertGreaterEqual(xmr_fee_piconero, naive_xmr_fee) + + def test_precision_edge_cases_no_overflow(self): + """Test that precision fixes don't cause integer overflow or other edge cases.""" + import math + from decimal import Decimal + + # Test very large amounts + large_amount = Decimal("999999999.999999999") + + # Should not overflow when converting to atomic units + try: + doge_koinu = math.ceil(large_amount * 100_000_000) + xmr_piconero = math.ceil(large_amount * 1_000_000_000_000) + + # Should be reasonable values + self.assertGreater(doge_koinu, 0) + self.assertGreater(xmr_piconero, 0) + + # Should be finite + self.assertTrue(math.isfinite(doge_koinu)) + self.assertTrue(math.isfinite(xmr_piconero)) + + except OverflowError: + self.fail("Precision calculations should not cause overflow") + + if __name__ == "__main__": unittest.main() diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index 750b85f..a14c1cb 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -21,6 +21,7 @@ from . import ( from decimal import Decimal import json +import math import time import uuid as _uuid import urllib.request @@ -242,7 +243,9 @@ def crypto_xmr_start(request): fee_buffer_xmr = Decimal(settings.get("monero.fee_buffer", "0.0001")) xmr_amount_with_fee = Decimal(str(xmr_amount)) + fee_buffer_xmr - expected_piconero = int(xmr_amount_with_fee * 1_000_000_000_000) + expected_piconero = math.ceil( + xmr_amount_with_fee * 1_000_000_000_000 + ) # Round UP for quotes # Quote expiry - use shop-specific setting shop = invoice.shop @@ -301,7 +304,9 @@ def crypto_xmr_start(request): ) # Convert fee to atomic units (piconero) for storage - estimated_fee_piconero = int(fee_buffer_xmr * 1_000_000_000_000) + estimated_fee_piconero = math.ceil( + fee_buffer_xmr * 1_000_000_000_000 + ) # Round UP for fees # Persist CryptoPayment crypto_payment = CryptoPayment( @@ -504,7 +509,9 @@ def crypto_doge_start(request): # 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 - expected_koinu = int(doge_amount_with_fee * 100_000_000) # Convert to koinu + expected_koinu = math.ceil( + doge_amount_with_fee * 100_000_000 + ) # Round UP for quotes # Quote expiry - use shop-specific setting shop = invoice.shop @@ -581,7 +588,9 @@ def crypto_doge_start(request): ) # Convert fee to atomic units (koinu) for storage - estimated_fee_koinu = int(fee_buffer_doge * 100_000_000) + estimated_fee_koinu = math.ceil( + fee_buffer_doge * 100_000_000 + ) # Round UP for fees # Persist CryptoPayment (using account_index=0, subaddress_index=0 for Bitcoin-like coins) crypto_payment = CryptoPayment( @@ -1125,9 +1134,9 @@ def estimate_monero_fee_for_quote(settings, shop_sweep_to_address, amount_picone }, ) - estimated_fee_piconero = int( + estimated_fee_piconero = math.ceil( test_transfer_result.get("fee", 100000000000) - ) # Fallback to 0.0001 XMR + ) # Fallback to 0.0001 XMR, round UP for fees # Double the fee to cover both inbound (customer) and outbound (sweep) transactions total_fee_piconero = estimated_fee_piconero * 2