From d2d09e88515d38899ff77f5cf86f100a974d3400 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 16:19:23 -0400 Subject: [PATCH 01/11] Fix Dogecoin multi-output refunds with proper fee handling - Add payment amount to shop output for overpayment refunds - Both XMR and DOGE now send payment + restocking fee to shop - Fix insufficient funds error by using conservative fee buffer (0.01 DOGE) - Round outputs down to 3 decimal places to avoid precision issues - Update sendmany to try actual account first (where funds are located) - Add better error handling for different account formats - Fix fee calculation to properly account for Dogecoin's fee-on-top model The issue was that sendmany adds network fee on top of outputs, and we were trying to send exactly the wallet balance, leaving no room for fees. Now we reserve 0.01 DOGE for fees and round conservatively. --- Makefile | 42 +++- make_post_sell/lib/crypto_watcher/__init__.py | 173 ++++++++------ .../lib/crypto_watcher/crypto_clients.py | 19 +- .../crypto_watcher/crypto_payment_rescue.py | 222 +++++++++++++++-- make_post_sell/models/invoice.py | 4 +- make_post_sell/templates/crypto_checkout.j2 | 16 +- .../templates/crypto_quotes_history.j2 | 2 +- make_post_sell/tests/test_crypto_watcher.py | 79 +++--- .../tests/test_multi_output_refunds.py | 226 ++++++++++++++++++ make_post_sell/views/crypto.py | 35 ++- 10 files changed, 661 insertions(+), 157 deletions(-) create mode 100644 make_post_sell/tests/test_multi_output_refunds.py diff --git a/Makefile b/Makefile index ba0a29a..8eb4329 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,8 @@ help: @echo " make dogecoin-node - Start Dogecoin daemon (pruned mode)" @echo " make dogecoin-node-full - Start Dogecoin daemon (full blockchain)" @echo " make dogecoin-status - Check sync status and wallet info" + @echo " make dogecoin-check-fee - Check current transaction fee setting" + @echo " make dogecoin-fix-fee - Fix high transaction fee (sets to 0.001 DOGE)" @echo "" @echo "WALLET MANAGEMENT:" @echo " make sweep-check - Check hot wallet balances (dev tool)" @@ -180,9 +182,10 @@ http: venv @echo "Starting simple HTTP server on port 8000..." $(PYTHON) -m http.server 8000 -# Run the crypto watcher service for monitoring Monero payments. +# Run the crypto watcher service for monitoring crypto payments. crypto-watcher: venv config @echo "Starting crypto payment watcher..." + @echo "💡 TIP: If Dogecoin refunds fail, check fee: make dogecoin-check-fee" $(VENV_DIR)/bin/crypto_watcher $(DATA_DIR)/$(CONFIG_FILE) # Run the crypto watcher once (for testing or manual processing). @@ -564,6 +567,12 @@ dogecoin-config: check-dogecoin @echo "" >> $(HOME)/.dogecoin/dogecoin.conf @echo "# Hot wallet for Make Post Sell" >> $(HOME)/.dogecoin/dogecoin.conf @echo "wallet=make_post_sell_hot_wallet" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Transaction fee settings" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Set reasonable fee for multi-output transactions (sendmany)" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "paytxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Minimum fee per KB (prevents 0-fee transactions)" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "mintxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf @echo "" @echo "✓ Configuration created at: $(HOME)/.dogecoin/dogecoin.conf" @echo "" @@ -621,6 +630,37 @@ dogecoin-node-full: check-dogecoin -rpcallowip=127.0.0.1 \ -server=1 +# Check and fix Dogecoin transaction fee +dogecoin-check-fee: check-dogecoin + @echo "=== Checking Dogecoin Transaction Fee ===" + @if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \ + echo "❌ Dogecoin daemon not running"; \ + echo "Start with: make dogecoin-node"; \ + exit 1; \ + fi + @fee=$$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ','); \ + echo "Current transaction fee: $$fee DOGE"; \ + if [ "$$(echo "$$fee > 0.001" | bc -l)" = "1" ]; then \ + echo "⚠️ WARNING: Transaction fee is too high!"; \ + echo "This may cause sendmany (multi-output transactions) to fail."; \ + echo "Run 'make dogecoin-fix-fee' to set reasonable fee."; \ + else \ + echo "✓ Transaction fee is reasonable"; \ + fi + +# Set reasonable Dogecoin transaction fee +dogecoin-fix-fee: check-dogecoin + @echo "=== Setting Dogecoin Transaction Fee ===" + @if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \ + echo "❌ Dogecoin daemon not running"; \ + echo "Start with: make dogecoin-node"; \ + exit 1; \ + fi + @echo "Setting transaction fee to 0.001 DOGE..." + @dogecoin-cli settxfee 0.001 + @echo "✓ Transaction fee updated" + @echo "New fee: $$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ',') DOGE" + # Check Dogecoin sync status and wallet info dogecoin-status: check-dogecoin @echo "=== Dogecoin Node Status ===" diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 0d4c251..1890efd 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -864,14 +864,16 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None # Get dynamic fee estimate by doing a test transfer with do_not_relay=true try: # Estimate fee by doing a test transfer without broadcasting - # This gives accurate fee based on actual transaction size + # Use 96% of amount to leave room for fee in the same subaddress + test_amount_piconero = int(payment_amount_piconero * Decimal("0.96")) + test_transfer_result = client._call( "transfer", { "destinations": [ { "address": crypto_payment.shop_sweep_to_address, - "amount": payment_amount_piconero, + "amount": test_amount_piconero, } ], "account_index": crypto_payment.account_index, @@ -881,18 +883,24 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None "get_tx_metadata": True, }, ) - estimated_fee_piconero = int( + dynamic_fee_piconero = int( test_transfer_result.get("fee", 100000000000) ) # Fallback to ~0.0001 XMR + # Add 10% margin to the dynamic fee estimate to ensure sweep always works + estimated_fee_piconero = int(dynamic_fee_piconero * Decimal("1.1")) log.payment_info( crypto_payment, - f"Dynamic fee estimate: {estimated_fee_piconero / atomic_units} XMR (single transaction)", + f"Dynamic fee estimate: {dynamic_fee_piconero / atomic_units} XMR, using {estimated_fee_piconero / atomic_units} XMR (with 10% margin)", ) except Exception as e: - # Fallback to hardcoded fee if RPC call fails - estimated_fee_piconero = int(Decimal("0.0001") * atomic_units) + # Fallback based on historical data: actual fees ~0.0000306 XMR + # Use 3x multiplier for safety margin + estimated_fee_piconero = int( + Decimal("0.0000918") * atomic_units + ) # ~91,800,000 piconero (3x typical fee) log.error_with_context( - "Failed to get dynamic fee estimate, using fallback", e + f"Failed to get dynamic fee estimate, using 3x typical fee as fallback: {e}", + e, ) # Calculate transfer amount: payment minus fee and reserve for pending refunds @@ -2758,85 +2766,98 @@ def process_payment( env_request.dbsession, crypto_payment ) - # EARLY DETECTION: Check for overpayments on existing received payments + # Check if received payment has enough confirmations to become confirmed elif ( - payment_rescue - and crypto_payment.invoice - and crypto_payment.invoice.user - and crypto_payment.status - == CryptoPayment.STATUS_RECEIVED # Only check received status - and received_amount_int - > expected_amount_int # Check current received amount for overpayment + crypto_payment.status == CryptoPayment.STATUS_RECEIVED + and received_amount_int >= expected_amount_int # Has enough amount and early_min_confs >= int( crypto_payment.confirmations_required - ) # Wait for required confirmations + ) # Has enough confirmations ): - # Overpayment detected - check if it exceeds threshold - coin_config = get_coin_config(crypto_payment.coin_type) - atomic_units = coin_config["atomic_units"] - received_crypto = Decimal(received_amount_int) / atomic_units - expected_crypto = Decimal(expected_amount_int) / atomic_units - - log.payment_info( - crypto_payment, - f"EARLY DETECTION - Potential overpayment: " - f"received {received_crypto} {crypto_payment.coin_type}, expected {expected_crypto} {crypto_payment.coin_type} " - f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - checking threshold", - ) - - # Check if overpayment exceeds 5% threshold using PaymentRescue logic - refund_details = payment_rescue.handle_overpayment( - crypto_payment, - expected_crypto, - received_crypto, - crypto_payment.invoice.user, - ) - - if refund_details: - # Overpayment exceeds threshold - process refund + # Check for exact payment first + if received_amount_int == expected_amount_int: log.payment_info( crypto_payment, - f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}", - ) - - # Mark as confirmed with overpayment detected (refund pending) - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY - crypto_payment.refund_reason = refund_details["reason"] - - # Try refund immediately since we have enough confirmations - result = payment_rescue.execute_refund( - refund_details, crypto_payment - ) - if result["success"]: - log.payment_info( - crypto_payment, f"Excess refunded: TX {result['tx_hash']}" - ) - # Mark as confirmed with overpayment refunded - crypto_payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED - ) - crypto_payment.refund_tx_hash = result["tx_hash"] - crypto_payment.refund_confirmations = 0 # Just sent - - # Finalize invoice since core payment amount is sufficient - finalize_invoice(env_request, crypto_payment, send_emails=True) - - # Note: Restocking fee will be swept when refund is fully confirmed - # to avoid double-sweeping before refund transaction is safely confirmed - else: - log.payment_error( - crypto_payment, - f"Refund failed for overpayment: {result['error']}", - ) - else: - # Overpayment within acceptable threshold - just confirm normally - log.payment_info( - crypto_payment, - "EARLY DETECTION - Overpayment within 5% threshold - confirming normally", + f"Exact payment confirmed with {early_min_confs}/{crypto_payment.confirmations_required} confirmations", ) crypto_payment.status = CryptoPayment.STATUS_CONFIRMED finalize_invoice(env_request, crypto_payment, send_emails=True) + # EARLY DETECTION: Check for overpayments on existing received payments + elif ( + payment_rescue + and crypto_payment.invoice + and crypto_payment.invoice.user + and received_amount_int > expected_amount_int # Overpayment + ): + # Overpayment detected - check if it exceeds threshold + coin_config = get_coin_config(crypto_payment.coin_type) + atomic_units = coin_config["atomic_units"] + received_crypto = Decimal(received_amount_int) / atomic_units + expected_crypto = Decimal(expected_amount_int) / atomic_units + + log.payment_info( + crypto_payment, + f"EARLY DETECTION - Potential overpayment: " + f"received {received_crypto} {crypto_payment.coin_type}, expected {expected_crypto} {crypto_payment.coin_type} " + f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - checking threshold", + ) + + # Check if overpayment exceeds 5% threshold using PaymentRescue logic + refund_details = payment_rescue.handle_overpayment( + crypto_payment, + expected_crypto, + received_crypto, + crypto_payment.invoice.user, + ) + + if refund_details: + # Overpayment exceeds threshold - process refund + log.payment_info( + crypto_payment, + f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}", + ) + + # Mark as confirmed with overpayment detected (refund pending) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY + crypto_payment.refund_reason = refund_details["reason"] + + # Try refund immediately since we have enough confirmations + result = payment_rescue.execute_refund( + refund_details, crypto_payment + ) + if result["success"]: + log.payment_info( + crypto_payment, + f"Excess refunded: TX {result['tx_hash']}", + ) + # Mark as confirmed with overpayment refunded + crypto_payment.status = ( + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED + ) + crypto_payment.refund_tx_hash = result["tx_hash"] + crypto_payment.refund_confirmations = 0 # Just sent + + # Finalize invoice since core payment amount is sufficient + finalize_invoice( + env_request, crypto_payment, send_emails=True + ) + + # Note: Restocking fee will be swept when refund is fully confirmed + # to avoid double-sweeping before refund transaction is safely confirmed + else: + log.payment_error( + crypto_payment, + f"Refund failed for overpayment: {result['error']}", + ) + else: + # Overpayment within acceptable threshold - just confirm normally + log.payment_info( + crypto_payment, + "EARLY DETECTION - Overpayment within 5% threshold - confirming normally", + ) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED + finalize_invoice(env_request, crypto_payment, send_emails=True) # Check for overpayment (exact match or overpaid) - for first payment elif ( diff --git a/make_post_sell/lib/crypto_watcher/crypto_clients.py b/make_post_sell/lib/crypto_watcher/crypto_clients.py index 9702e30..cc7b3c6 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_clients.py +++ b/make_post_sell/lib/crypto_watcher/crypto_clients.py @@ -300,9 +300,22 @@ class DogecoinClient: """Send Dogecoin to an address. Returns transaction ID.""" return self._call("sendtoaddress", [address, amount, comment]) - def sendmany(self, from_label: str, addresses_amounts: Dict[str, float]) -> str: - """Send to multiple addresses at once. More efficient for sweeping.""" - return self._call("sendmany", [from_label, addresses_amounts]) + def sendmany(self, from_label: str, addresses_amounts: Dict[str, float], minconf: int = 1, comment: str = "") -> str: + """Send to multiple addresses at once. More efficient for sweeping. + + Args: + from_label: Account label (use "" for default account) + addresses_amounts: Dict mapping addresses to amounts + minconf: Minimum confirmations (default: 1) + comment: Transaction comment (optional) + """ + # Build params list - Dogecoin expects specific parameter order + params = [from_label, addresses_amounts] + if minconf != 1 or comment: + params.append(minconf) + if comment: + params.append(comment) + return self._call("sendmany", params) # Blockchain Info diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index 87c7983..48e559b 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -119,6 +119,7 @@ class PaymentRescue: "excess_amount": excess_amount, "refund_amount": refund_amount, "fee_amount": excess_amount - refund_amount, + "payment_amount": expected_amount, # Shop should get the actual payment too! "reason": f"Overpayment exceeds {int(OVERPAYMENT_THRESHOLD_PERCENT * 100)}% threshold: received {received_amount} but expected {expected_amount}", } @@ -158,10 +159,14 @@ 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 Args: refund_details: dict with refund information - payment: CryptoPayment object (needed for account_index) + payment: CryptoPayment object (needed for account_index and shop_sweep_to_address) Returns: dict with transaction details or raises exception @@ -171,6 +176,9 @@ class PaymentRescue: logger = logging.getLogger(__name__) refund_amount_coin = refund_details["refund_amount"] + fee_amount_coin = refund_details["fee_amount"] + # For overpayments, also include the actual payment amount + payment_amount_coin = refund_details.get("payment_amount", Decimal("0")) # Get coin type and atomic units for proper logging coin_type = payment.coin_type if payment else "XMR" @@ -179,6 +187,8 @@ class PaymentRescue: coin_config = get_coin_config(coin_type) atomic_units = int(coin_config["atomic_units"]) refund_amount_atomic = int(refund_amount_coin * atomic_units) + fee_amount_atomic = int(fee_amount_coin * atomic_units) + payment_amount_atomic = int(payment_amount_coin * atomic_units) atomic_unit_name = ( "piconero" @@ -186,11 +196,22 @@ class PaymentRescue: else "koinu" if coin_type == "DOGE" else "atomic units" ) + # Get shop sweep address - we'll handle missing address with a clear error + shop_sweep_address = payment.shop_sweep_to_address if payment else None + if payment: logger.info(f"Attempting to refund: {payment}") logger.info( f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})" ) + logger.info( + f"Fee amount to shop: {fee_amount_coin} {coin_type} ({fee_amount_atomic} {atomic_unit_name})" + ) + if payment_amount_coin > 0: + logger.info( + f"Payment amount to shop: {payment_amount_coin} {coin_type} ({payment_amount_atomic} {atomic_unit_name})" + ) + logger.info(f"Shop sweep address: {shop_sweep_address}") logger.info(f"Refund address: {refund_details['refund_address']}") logger.info(f"Refund reason: {refund_details['reason']}") else: @@ -242,6 +263,7 @@ class PaymentRescue: logger.info("Checking Dogecoin wallet balance") balance_result = self.crypto_client.getbalance() logger.info(f"Dogecoin wallet balance: {balance_result} DOGE") + if balance_result < refund_amount_coin: logger.warning( @@ -253,14 +275,43 @@ class PaymentRescue: try: # Create coin-specific refund transaction if coin_type == "XMR": - # Monero uses the transfer RPC with atomic units (piconero) + # Monero always uses multi-output transfer + if not shop_sweep_address: + raise ValueError("Shop sweep address is required for refunds") + + # Build destinations array + destinations = [ + { + "address": refund_details["refund_address"], + "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, + }) + + if payment_amount_atomic > 0: + logger.info( + f"Monero multi-output transfer: " + f"{refund_amount_atomic} piconero to customer, " + f"{shop_amount_atomic} piconero to shop (payment: {payment_amount_atomic} + fee: {fee_amount_atomic})" + ) + else: + logger.info( + f"Monero multi-output transfer: " + f"{refund_amount_atomic} piconero to customer, " + f"{fee_amount_atomic} piconero to shop" + ) + transfer_params = { - "destinations": [ - { - "address": refund_details["refund_address"], - "amount": refund_amount_atomic, - } - ], + "destinations": destinations, "account_index": account_index, "get_tx_key": True, "do_not_relay": False, @@ -270,23 +321,148 @@ class PaymentRescue: tx_result = self.crypto_client._call("transfer", transfer_params) elif coin_type == "DOGE": - # Dogecoin uses sendtoaddress RPC with coin amounts (not atomic units) + # 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) - logger.info( - f"Calling Dogecoin sendtoaddress: {refund_amount_doge} DOGE to {refund_details['refund_address']}" - ) - logger.info( - f"Dogecoin client config - URL: {self.crypto_client.rpc_url}, User: {self.crypto_client.rpc_user}" - ) - logger.info( - f"About to call sendtoaddress with params: address={refund_details['refund_address']}, amount={refund_amount_doge}" - ) - tx_hash = self.crypto_client.sendtoaddress( - refund_details["refund_address"], - refund_amount_doge, - f"Overpayment refund for {refund_details['payment_id']}", - ) + 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 + + # 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 + # Use conservative fee estimate to ensure we have enough room + # This accounts for fee calculation differences and rounding + estimated_fee = 0.01 # Very conservative to avoid insufficient funds errors + + # Check if we need to adjust for fees + if total_output + estimated_fee > balance_result: + # Calculate how much we need to reduce + shortage = (total_output + estimated_fee) - balance_result + logger.info(f"Need to reduce outputs by {shortage} DOGE to account for network fee") + + # Reduce both amounts proportionally + refund_ratio = refund_amount_doge / total_output + shop_ratio = shop_amount_doge / total_output + + # Reduce and round conservatively (round down to avoid exceeding balance) + import math + refund_reduction = shortage * refund_ratio + shop_reduction = shortage * shop_ratio + + # Round down to 3 decimal places for cleaner amounts + refund_amount_doge = math.floor((refund_amount_doge - refund_reduction) * 1000) / 1000 + shop_amount_doge = math.floor((shop_amount_doge - shop_reduction) * 1000) / 1000 + + logger.info(f"Adjusted amounts - Customer: {refund_amount_doge} DOGE, Shop: {shop_amount_doge} DOGE") + + # Build final outputs with adjusted amounts + outputs[refund_details["refund_address"]] = refund_amount_doge + outputs[shop_sweep_address] = shop_amount_doge + + if payment_amount_doge > 0: + logger.info( + f"Calling Dogecoin sendmany with multi-output: " + f"{refund_amount_doge} DOGE to customer, " + f"{shop_amount_doge} DOGE to shop (payment: {payment_amount_doge} + fee: {fee_amount_doge})" + ) + else: + logger.info( + f"Calling Dogecoin sendmany with multi-output: " + f"{refund_amount_doge} DOGE to customer, " + f"{fee_amount_doge} DOGE to shop" + ) + + # First check if we have enough balance including fees + # Already accounted for in shop_amount_doge adjustment above + total_needed = refund_amount_doge + shop_amount_doge + + if balance_result < total_needed: + logger.error( + f"Insufficient balance for refund: " + f"have {balance_result} DOGE, need {total_needed} DOGE" + ) + raise ValueError(f"Insufficient balance: have {balance_result}, need {total_needed}") + + # Get the account label for the payment address + # First, try to get account from the address + from_account = "" + try: + # Try newer method first (getaddressinfo) + addr_info = self.crypto_client._call("getaddressinfo", [payment.address]) + if addr_info and "labels" in addr_info and addr_info["labels"]: + from_account = addr_info["labels"][0] + logger.info(f"Using account label from getaddressinfo: {from_account}") + except Exception as e: + # Fall back to older method (getaccount) + try: + from_account = self.crypto_client._call("getaccount", [payment.address]) + if from_account: + logger.info(f"Using account from getaccount: {from_account}") + except Exception as e2: + # Last resort: try to find in unspent outputs + try: + unspent = self.crypto_client._call("listunspent", []) + for utxo in unspent: + if utxo.get("address") == payment.address: + from_account = utxo.get("account", "") + if from_account: + logger.info(f"Using account from unspent output: {from_account}") + break + except Exception as e3: + logger.warning(f"Could not get account for address: {e}, {e2}, {e3}") + from_account = "" + + # sendmany(fromaccount, {address:amount,...}, minconf=1) + # Don't include comment - it causes issues with many Dogecoin versions + + # Ensure outputs are properly formatted - sometimes decimal precision issues cause 500 errors + # Convert outputs to ensure they're plain floats, not Decimal objects + clean_outputs = {} + for addr, amount in outputs.items(): + clean_outputs[addr] = float(amount) + + # If account has special characters, try different approaches + logger.info(f"Attempting sendmany with account='{from_account}', outputs={clean_outputs}") + + # Try different sendmany approaches until one works + tx_hash = None + attempts = [ + # 1. Try with the actual account first (where the funds are) + ("actual account", lambda: self.crypto_client.sendmany(from_account, clean_outputs, 1)), + # 2. Try with empty account (default) + ("empty account", lambda: self.crypto_client.sendmany("", clean_outputs, 1)), + # 3. Try actual account without minconf + ("actual account without minconf", lambda: self.crypto_client.sendmany(from_account, clean_outputs)), + # 4. Try with explicit default account "*" + ("default account '*'", lambda: self.crypto_client.sendmany("*", clean_outputs, 1)), + ] + + last_error = None + for attempt_name, attempt_func in attempts: + try: + logger.info(f"Trying sendmany with {attempt_name}") + tx_hash = attempt_func() + logger.info(f"Success with {attempt_name}: {tx_hash}") + break + except Exception as e: + last_error = e + logger.warning(f"Failed with {attempt_name}: {e}") + continue + + if not tx_hash and last_error: + raise last_error + tx_result = {"tx_hash": tx_hash} else: diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index e980eb1..eb499d0 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -288,7 +288,7 @@ class Invoice(RBase, Base): @property def is_paid(self): """Check if this invoice has been successfully paid.""" - return self.payment_status in ["confirmed", "confirmed_overpaid", "paid"] + return self.payment_status in ["confirmed", "confirmed-overpay", "confirmed-complete", "paid"] def get_invoice_by_id(dbsession, invoice_id): @@ -354,7 +354,7 @@ def delete_invoice_by_id(dbsession, invoice_id): } # Guard: Don't delete successful payments - those are legitimate transactions - successful_statuses = ["confirmed", "confirmed-overpaid"] + successful_statuses = ["confirmed", "confirmed-overpay", "confirmed-complete"] if crypto_payment.status in successful_statuses: return { "success": False, diff --git a/make_post_sell/templates/crypto_checkout.j2 b/make_post_sell/templates/crypto_checkout.j2 index 7d46777..fceba94 100644 --- a/make_post_sell/templates/crypto_checkout.j2 +++ b/make_post_sell/templates/crypto_checkout.j2 @@ -58,7 +58,7 @@

Total Amount: {{ '%.12f' % amount_crypto }} {{ coin_symbol }}

Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}

Payment ID: {{ payment_id }}

- {% if status in ['confirmed', 'confirmed-overpaid', 'confirmed-overpaid-refunded'] and has_invoice and invoice_id %} + {% if status in ['confirmed', 'confirmed-complete', 'confirmed-overpay', 'confirmed-overpay-refunded', 'confirmed-overpay-refunded-complete'] and has_invoice and invoice_id %}

Invoice ID: {{ invoice_id }}

{% endif %} {% else %} @@ -67,7 +67,7 @@

Total Amount: {{ '%.8f' % amount_crypto }} {{ coin_symbol }}

Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}

Payment ID: {{ payment_id }}

- {% if status in ['confirmed', 'confirmed-overpaid', 'confirmed-overpaid-refunded'] and has_invoice and invoice_id %} + {% if status in ['confirmed', 'confirmed-complete', 'confirmed-overpay', 'confirmed-overpay-refunded', 'confirmed-overpay-refunded-complete'] and has_invoice and invoice_id %}

Invoice ID: {{ invoice_id }}

{% endif %} {% endif %} @@ -191,7 +191,7 @@ const currentStatus = statusEl ? statusEl.textContent.trim() : ''; // Use smart redirect URL for confirmed states, crypto-quotes for terminal states - if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { invoiceLink.href = redirectUrl || '/u/purchases'; // Determine button text based on redirect URL if (redirectUrl && redirectUrl.includes('/p/')) { @@ -250,7 +250,7 @@ if (expiresEl) { expiresEl.parentNode.style.display = 'none'; } - } else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-overpaid')) { + } else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-complete' || lastKnownStatus === 'confirmed-overpay')) { // Payment confirmed - replace with invoice link replaceButtonsWithInvoiceLink(); @@ -377,7 +377,7 @@ console.log(`Status changed from ${lastKnownStatus} to ${currentStatus}`); // Show confirmations section when payment is received or later - if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { if (confirmationsEl) { confirmationsEl.style.display = 'block'; } @@ -399,7 +399,7 @@ if (expiresEl && expiresEl.parentNode) { expiresEl.parentNode.style.display = 'none'; } - } else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + } else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { // Payment confirmed - replace with smart redirect link replaceButtonsWithInvoiceLink(data.redirect_url); @@ -410,7 +410,7 @@ // Redirect only if status changed from non-confirmed to confirmed (via polling) // Don't redirect if user browsed directly to already-confirmed quote - if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-overpaid') { + if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-complete' && lastKnownStatus !== 'confirmed-overpay') { setTimeout(() => { // Use smart redirect URL if available, otherwise fallback to purchases page window.location.href = data.redirect_url || '/u/purchases'; @@ -470,7 +470,7 @@ // Stop polling if we've reached a terminal status or confirmed status const currentStatus = statusEl ? statusEl.textContent.trim() : ''; - if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed') { + if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete') { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; diff --git a/make_post_sell/templates/crypto_quotes_history.j2 b/make_post_sell/templates/crypto_quotes_history.j2 index 4557baa..118f495 100644 --- a/make_post_sell/templates/crypto_quotes_history.j2 +++ b/make_post_sell/templates/crypto_quotes_history.j2 @@ -58,7 +58,7 @@ {% endif %}
- {% if payment.status == 'confirmed' or payment.status == 'confirmed-overpaid' or payment.status == 'confirmed-overpaid-refunded' %} + {% if payment.status == 'confirmed' or payment.status == 'confirmed-complete' or payment.status == 'confirmed-overpay' or payment.status == 'confirmed-overpay-refunded' or payment.status == 'confirmed-overpay-refunded-complete' %} {% if payment.has_invoice %} View Invoice diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index 7557056..23f5509 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -1154,8 +1154,8 @@ class AutoSweepTests(unittest.TestCase): # Check sweep tracking fields were set self.assertEqual( - payment.swept_amount, 490000000000 - ) # 0.5 XMR - 0.01 XMR fee = 0.49 XMR + payment.swept_amount, 489000000000 + ) # 0.5 XMR - (0.01 XMR fee * 1.1 margin) self.assertEqual(payment.swept_tx_hash, "transfer_tx_123") self.assertIsNotNone(payment.swept_timestamp) @@ -1230,7 +1230,7 @@ class AutoSweepTests(unittest.TestCase): # Verify that swept_tx_hash was set (from transfer, not sweep) self.assertEqual(payment.swept_tx_hash, "transfer_tx_123") - self.assertEqual(payment.swept_amount, 495000000000) # 0.5 XMR - 0.005 XMR fee + self.assertEqual(payment.swept_amount, 494500000000) # 0.5 XMR - (0.005 XMR fee * 1.1 margin) self.assertEqual(payment.swept_network_fee, 5000000000) self.assertIsNotNone(payment.swept_timestamp) @@ -3101,8 +3101,8 @@ class RefundTypeTests(unittest.TestCase): # Verify out of stock refund is larger self.assertGreater(full_refund, normal_refund) - def test_doge_refund_execution_uses_correct_rpc(self): - """Test DOGE refund execution uses sendtoaddress RPC.""" + def test_doge_refund_execution_uses_sendmany_rpc(self): + """Test DOGE refund execution uses sendmany RPC for multi-output.""" from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import ( PaymentRescue, ) @@ -3113,9 +3113,10 @@ class RefundTypeTests(unittest.TestCase): doge_payment.coin_type = "DOGE" doge_payment.current_confirmations = 2 # Sufficient confirmations doge_payment.account_index = None # DOGE doesn't use account_index + doge_payment.shop_sweep_to_address = "DShopSweepAddress789" # Shop address for fee mock_client = MagicMock() - mock_client.sendtoaddress.return_value = "doge-tx-hash-123" + mock_client._call.return_value = "doge-tx-hash-123" mock_client.getbalance.return_value = 100.0 # Sufficient DOGE balance rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3131,19 +3132,22 @@ class RefundTypeTests(unittest.TestCase): result = rescue.execute_refund(refund_details, doge_payment) - # Verify DOGE-specific RPC was called - # mock_client.sendtoaddress.assert_called_once_with("DTestAddress123", 4.55, "Overpayment refund for doge-payment-123") - mock_client.sendtoaddress.assert_called_once_with( - "DTestAddress123", 4.55, "Overpayment refund for doge-payment-123" - ) + # Verify sendmany was called with multi-output + mock_client._call.assert_called_once() + call_args = mock_client._call.call_args[0] + self.assertEqual(call_args[0], "sendmany") + self.assertEqual(call_args[1][0], "") # fromaccount + outputs = call_args[1][1] + self.assertEqual(outputs["DTestAddress123"], 4.55) # Customer refund + self.assertEqual(outputs["DShopSweepAddress789"], 0.45) # Shop fee mock_client.getbalance.assert_called_once() # Balance check # Verify successful result self.assertTrue(result["success"]) self.assertEqual(result["tx_hash"], "doge-tx-hash-123") - def test_xmr_refund_execution_uses_correct_rpc(self): - """Test XMR refund execution uses transfer RPC with atomic units.""" + def test_xmr_refund_execution_uses_multi_output_transfer(self): + """Test XMR refund execution uses transfer RPC with multi-output.""" from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import ( PaymentRescue, ) @@ -3154,6 +3158,7 @@ class RefundTypeTests(unittest.TestCase): xmr_payment.coin_type = "XMR" xmr_payment.current_confirmations = 10 # Sufficient confirmations xmr_payment.account_index = 5 + xmr_payment.shop_sweep_to_address = "4ShopSweepAddressXMR789" # Shop address for fee mock_client = MagicMock() mock_client._call.side_effect = [ @@ -3186,11 +3191,18 @@ class RefundTypeTests(unittest.TestCase): self.assertEqual(expected_transfer_call[0][0], "transfer") transfer_params = expected_transfer_call[0][1] self.assertEqual(transfer_params["account_index"], 5) + self.assertEqual(len(transfer_params["destinations"]), 2) # Two destinations self.assertEqual( transfer_params["destinations"][0]["address"], "4TestXMRAddress123" ) # 1.82 XMR = 1.82 * 10^12 = 1,820,000,000,000 piconero self.assertEqual(transfer_params["destinations"][0]["amount"], 1820000000000) + # Shop fee destination + self.assertEqual( + transfer_params["destinations"][1]["address"], "4ShopSweepAddressXMR789" + ) + # 0.18 XMR = 0.18 * 10^12 = 180,000,000,000 piconero + self.assertEqual(transfer_params["destinations"][1]["amount"], 180000000000) # Verify successful result self.assertTrue(result["success"]) @@ -3267,6 +3279,7 @@ class RefundTypeTests(unittest.TestCase): unsupported_payment.id = "unsupported-payment" unsupported_payment.coin_type = "UNSUPPORTED" unsupported_payment.current_confirmations = 10 + unsupported_payment.shop_sweep_to_address = "UNSUPPORTEDshopAddress" mock_client = MagicMock() rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3274,7 +3287,8 @@ class RefundTypeTests(unittest.TestCase): refund_details = { "payment_id": "unsupported-payment", "refund_address": "unsupported-address", - "refund_amount": Decimal("1.0"), + "refund_amount": Decimal("0.91"), + "fee_amount": Decimal("0.09"), "reason": "Test unsupported coin", } @@ -3296,9 +3310,10 @@ class RefundTypeTests(unittest.TestCase): doge_payment.coin_type = "DOGE" doge_payment.current_confirmations = 2 doge_payment.account_index = None + doge_payment.shop_sweep_to_address = "DShopPrecisionAddress" mock_client = MagicMock() - mock_client.sendtoaddress.return_value = "doge-precision-tx-123" + mock_client._call.return_value = "doge-precision-tx-123" mock_client.getbalance.return_value = 100.0 rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3317,14 +3332,18 @@ class RefundTypeTests(unittest.TestCase): # Should succeed self.assertTrue(result["success"]) + # Verify successful result + self.assertTrue(result["success"]) self.assertEqual(result["tx_hash"], "doge-precision-tx-123") - - # Verify sendtoaddress was called with rounded amount (8 decimal places) - mock_client.sendtoaddress.assert_called_once_with( - "DPrecisionTest123", - 1.03859035, # Rounded to 8 decimal places - "Overpayment refund for doge-precision-123", - ) + + # Verify sendmany was called with properly rounded amounts + mock_client._call.assert_called_once() + call_args = mock_client._call.call_args[0] + self.assertEqual(call_args[0], "sendmany") + outputs = call_args[1][1] + # Verify amounts are rounded to 8 decimal places + self.assertEqual(outputs["DPrecisionTest123"], 1.03859035) # Rounded from 1.0385903528 + self.assertEqual(outputs["DShopPrecisionAddress"], 0.11429455) # Rounded from 0.1142945472 def test_xmr_refund_amount_precision_handling(self): """Test XMR refund execution handles high precision amounts correctly.""" @@ -3794,8 +3813,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # The amount should be reduced by fee: 2500000000000 - 100000000 = 2499900000000 - expected_amount_after_fee = 2500000000000 - 100000000 + # The amount should be reduced by fee with 10% margin: 2500000000000 - (100000000 * 1.1) = 2499890000000 + expected_amount_after_fee = 2500000000000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) @@ -3885,8 +3904,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # Amount should be reduced by fee: 2500000000000 - 100000000 = 2499900000000 - expected_amount_after_fee = 2500000000000 - 100000000 + # Amount should be reduced by fee with 10% margin: 2500000000000 - (100000000 * 1.1) = 2499890000000 + expected_amount_after_fee = 2500000000000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) @@ -4005,8 +4024,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # The amount should be expected amount minus fee: 2000000000000 - 100000000 = 1999900000000 - expected_amount_after_fee = 2000000000000 - 100000000 + # The amount should be expected amount minus fee with 10% margin: 2000000000000 - (100000000 * 1.1) = 1999890000000 + expected_amount_after_fee = 2000000000000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) @@ -4177,8 +4196,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # The amount should be reduced by fee: 987654321000 - 100000000 = 987554321000 - expected_amount_after_fee = 987654321000 - 100000000 + # The amount should be reduced by fee with 10% margin: 987654321000 - (100000000 * 1.1) = 987544321000 + expected_amount_after_fee = 987654321000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) diff --git a/make_post_sell/tests/test_multi_output_refunds.py b/make_post_sell/tests/test_multi_output_refunds.py new file mode 100644 index 0000000..0a0debd --- /dev/null +++ b/make_post_sell/tests/test_multi_output_refunds.py @@ -0,0 +1,226 @@ +""" +Tests for multi-output refunds functionality. + +Tests that refunds can be sent to multiple addresses in a single transaction: +- Customer gets refund minus fee +- Shop owner gets the fee portion +""" + +import unittest +from unittest.mock import MagicMock, patch +from decimal import Decimal +import uuid + +from ..lib.crypto_watcher.crypto_payment_rescue import PaymentRescue, RESTOCKING_FEE_PERCENT +from ..models.crypto_payment import CryptoPayment + + +class TestMultiOutputRefunds(unittest.TestCase): + """Test multi-output refund functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_dbsession = MagicMock() + self.mock_client = MagicMock() + self.rescue = PaymentRescue(self.mock_dbsession, self.mock_client) + + # Create a mock payment with shop sweep address + self.payment = MagicMock(spec=CryptoPayment) + self.payment.id = uuid.uuid4() + self.payment.coin_type = "DOGE" + self.payment.account_index = 0 + self.payment.current_confirmations = 10 + self.payment.shop_sweep_to_address = "DShopSweepAddressTest123" + self.payment.__str__.return_value = f"Payment {self.payment.id}" + + def test_doge_multi_output_refund(self): + """Test DOGE refund with multi-output (customer + shop).""" + # Set up refund details + received_amount = Decimal("100.0") # 100 DOGE received + fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 9 DOGE fee + refund_amount = received_amount - fee_amount # 91 DOGE refund + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": received_amount, + "refund_amount": refund_amount, + "fee_amount": fee_amount, + "reason": "Test overpayment refund" + } + + # Mock the sendmany call + self.mock_client._call.return_value = "test_tx_hash_123" + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify multi-output sendmany was called + self.mock_client._call.assert_called_once() + call_args = self.mock_client._call.call_args[0] + + self.assertEqual(call_args[0], "sendmany") + self.assertEqual(call_args[1][0], "") # fromaccount + + # Check outputs + outputs = call_args[1][1] + self.assertEqual(len(outputs), 2) # Two outputs + self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=8) + self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=8) + + # Verify result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "test_tx_hash_123") + self.assertEqual(result["fee_charged"], fee_amount) + + def test_xmr_multi_output_refund(self): + """Test XMR refund with multi-output (customer + shop).""" + # Set up payment for XMR + self.payment.coin_type = "XMR" + + # Set up refund details + received_amount = Decimal("1.0") # 1 XMR received + fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 0.09 XMR fee + refund_amount = received_amount - fee_amount # 0.91 XMR refund + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "4CustomerRefundAddressXMR123", + "received_amount": received_amount, + "refund_amount": refund_amount, + "fee_amount": fee_amount, + "reason": "Test overpayment refund" + } + + # Mock the RPC calls + self.mock_client._call.side_effect = [ + # get_balance call + {"balance": 2000000000000, "unlocked_balance": 2000000000000}, + # transfer call + {"tx_hash": "xmr_test_tx_hash_456"} + ] + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Find the transfer call + transfer_call = None + for call in self.mock_client._call.call_args_list: + if call[0][0] == "transfer": + transfer_call = call + break + + self.assertIsNotNone(transfer_call) + transfer_params = transfer_call[0][1] + + # Check destinations + destinations = transfer_params["destinations"] + self.assertEqual(len(destinations), 2) # Two destinations + + # Customer refund destination + self.assertEqual(destinations[0]["address"], "4CustomerRefundAddressXMR123") + self.assertEqual(destinations[0]["amount"], 910000000000) # 0.91 XMR in piconero + + # Shop fee destination + self.assertEqual(destinations[1]["address"], "DShopSweepAddressTest123") + self.assertEqual(destinations[1]["amount"], 90000000000) # 0.09 XMR in piconero + + # Verify result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "xmr_test_tx_hash_456") + self.assertEqual(result["fee_charged"], fee_amount) + + def test_refund_no_shop_address_fails(self): + """Test refund fails when shop has no sweep address.""" + # Remove shop sweep address + self.payment.shop_sweep_to_address = None + + # Set up refund details + received_amount = Decimal("100.0") + fee_amount = received_amount * RESTOCKING_FEE_PERCENT + refund_amount = received_amount - fee_amount + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": received_amount, + "refund_amount": refund_amount, + "fee_amount": fee_amount, + "reason": "Test overpayment refund" + } + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify refund failed + self.assertFalse(result["success"]) + self.assertIn("Shop sweep address is required", result["error"]) + + # Verify no transaction was attempted + self.mock_client._call.assert_not_called() + self.mock_client.sendtoaddress.assert_not_called() + + def test_refund_zero_fee_still_multi_output(self): + """Test multi-output is used even when fee is zero.""" + # Set up refund with zero fee (shouldn't happen but test edge case) + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": Decimal("100.0"), + "refund_amount": Decimal("100.0"), + "fee_amount": Decimal("0"), + "reason": "Test refund with zero fee" + } + + # Mock the sendmany call + self.mock_client._call.return_value = "zero_fee_tx_123" + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify multi-output sendmany was still used + self.mock_client._call.assert_called_once() + call_args = self.mock_client._call.call_args[0] + self.assertEqual(call_args[0], "sendmany") + outputs = call_args[1][1] + self.assertEqual(outputs["DCustomerRefundAddress123"], 100.0) # Full refund + self.assertEqual(outputs["DShopSweepAddressTest123"], 0.0) # Zero fee + + # Verify result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "zero_fee_tx_123") + + def test_insufficient_confirmations(self): + """Test refund is delayed when insufficient confirmations.""" + # Set insufficient confirmations + self.payment.current_confirmations = 1 + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": Decimal("100.0"), + "refund_amount": Decimal("91.0"), + "fee_amount": Decimal("9.0"), + "reason": "Test refund" + } + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify refund was delayed + self.assertFalse(result["success"]) + self.assertIn("confirmations", result["error"]) + self.assertEqual(result["confirmations_needed"], 1) # Need 1 more confirmation + + # Verify no transaction was attempted + self.mock_client._call.assert_not_called() + self.mock_client.sendtoaddress.assert_not_called() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index b3c7ed0..7ef1d52 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -836,7 +836,7 @@ def crypto_xmr_status(request): ) else: request.session.flash(("Purchase completed!", "success")) - elif current_status == "confirmed-overpaid": + elif current_status == "confirmed-overpay": request.session.flash( ("Purchase completed! Overpayment will be refunded.", "success") ) @@ -872,7 +872,7 @@ def crypto_xmr_status(request): # Add smart redirect URL when payment is confirmed (any confirmed status) if ( - crypto_payment.status in ["confirmed", "confirmed-overpaid"] + crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"] and crypto_payment.invoice ): from ..views.cart import get_smart_purchase_redirect_url @@ -932,7 +932,7 @@ def crypto_doge_status(request): ) else: request.session.flash(("Purchase completed!", "success")) - elif current_status == "confirmed-overpaid": + elif current_status == "confirmed-overpay": request.session.flash( ("Purchase completed! Overpayment will be refunded.", "success") ) @@ -971,7 +971,7 @@ def crypto_doge_status(request): # Add smart redirect URL when payment is confirmed (any confirmed status) if ( - crypto_payment.status in ["confirmed", "confirmed-overpaid"] + crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"] and crypto_payment.invoice ): from ..views.cart import get_smart_purchase_redirect_url @@ -1205,11 +1205,13 @@ def get_payment_status_info(status): "pending": {"label": "Pending Payment", "color": "#ffc107"}, "received": {"label": "Payment Received", "color": "#17a2b8"}, "confirmed": {"label": "✓ Confirmed", "color": "#28a745"}, - "confirmed-overpaid": {"label": "✓ Confirmed (Overpaid)", "color": "#28a745"}, + "confirmed-complete": {"label": "✓ Confirmed (Complete)", "color": "#28a745"}, + "confirmed-overpay": {"label": "✓ Confirmed (Overpaid)", "color": "#28a745"}, "expired": {"label": "Expired", "color": "#6c757d"}, - "expired-refunded": {"label": "Expired - Refunded", "color": "#fd7e14"}, - "expired-refunded-complete": { - "label": "✓ Expired - Refunded", + "expired": {"label": "Expired", "color": "#6c757d"}, + "latepay-refunded": {"label": "Late Payment - Refunded", "color": "#fd7e14"}, + "latepay-refunded-complete": { + "label": "✓ Late Payment - Refunded", "color": "#fd7e14", }, "underpaid-refunded": {"label": "Underpaid - Refunded", "color": "#fd7e14"}, @@ -1217,7 +1219,11 @@ def get_payment_status_info(status): "label": "✓ Underpaid - Refunded", "color": "#fd7e14", }, - "confirmed-overpaid-refunded": { + "confirmed-overpay-refunded": { + "label": "✓ Overpaid - Refund Sent", + "color": "#28a745", + }, + "confirmed-overpay-refunded-complete": { "label": "✓ Overpaid - Refunded", "color": "#28a745", }, @@ -1230,16 +1236,19 @@ def get_payment_status_info(status): "label": "✓ Out of Stock - Refunded", "color": "#fd7e14", }, - "doublepay-refund": { + "doublepay-refunded": { "label": "Duplicate Payment - Refunding", "color": "#fd7e14", }, - "doublepay-refund-complete": { + "doublepay-refunded-complete": { "label": "✓ Duplicate Payment - Refunded", "color": "#fd7e14", }, - "no-refund": {"label": "No Refund Possible", "color": "#dc3545"}, - "no-refund-complete": {"label": "✓ No Refund Possible", "color": "#dc3545"}, + "latepay-not-refunded": {"label": "Late Payment - No Refund", "color": "#dc3545"}, + "underpaid-not-refunded": {"label": "Underpaid - No Refund", "color": "#dc3545"}, + "confirmed-overpay-not-refunded": {"label": "Overpaid - No Refund", "color": "#dc3545"}, + "out-of-stock-not-refunded": {"label": "Out of Stock - No Refund", "color": "#dc3545"}, + "doublepay-not-refunded": {"label": "Duplicate Payment - No Refund", "color": "#dc3545"}, } return status_mapping.get(status, {"label": status.title(), "color": "#6c757d"}) From d4a239d74883c6dd48f21a905c7f3372707c480f Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 16:27:33 -0400 Subject: [PATCH 02/11] Clean up multi-output refund code - Reduce fee buffer from 0.01 to 0.005 DOGE (actual fee was 0.0026) - Remove excessive logging throughout refund process - Simplify sendmany retry logic - just try funded account then default - Remove unnecessary balance checks and debug logging - Fix test mocks to use sendmany instead of _call This reduces the dust left in temporary wallets from ~0.011 to ~0.005 DOGE --- .../crypto_watcher/crypto_payment_rescue.py | 200 ++++-------------- .../tests/test_multi_output_refunds.py | 46 ++-- 2 files changed, 61 insertions(+), 185 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index 48e559b..d39fce8 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -201,26 +201,16 @@ class PaymentRescue: if payment: logger.info(f"Attempting to refund: {payment}") - logger.info( - f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})" - ) - logger.info( - f"Fee amount to shop: {fee_amount_coin} {coin_type} ({fee_amount_atomic} {atomic_unit_name})" - ) if payment_amount_coin > 0: logger.info( - f"Payment amount to shop: {payment_amount_coin} {coin_type} ({payment_amount_atomic} {atomic_unit_name})" + f"Multi-output refund - Customer: {refund_amount_coin} {coin_type}, " + f"Shop: {payment_amount_coin + fee_amount_coin} {coin_type} (payment + fee)" + ) + else: + logger.info( + f"Refund: {refund_amount_coin} {coin_type} to customer, " + f"{fee_amount_coin} {coin_type} fee to shop" ) - logger.info(f"Shop sweep address: {shop_sweep_address}") - logger.info(f"Refund address: {refund_details['refund_address']}") - logger.info(f"Refund reason: {refund_details['reason']}") - else: - logger.info(f"Attempting to refund payment {refund_details['payment_id']}") - logger.info( - f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})" - ) - logger.info(f"Refund address: {refund_details['refund_address']}") - logger.info(f"Refund reason: {refund_details['reason']}") # Check if incoming payment has enough confirmations before allowing refund from . import get_coin_config @@ -238,39 +228,13 @@ class PaymentRescue: - payment.current_confirmations, } - # Check wallet balance before attempting refund (coin-specific) - try: - if coin_type == "XMR": - account_index = payment.account_index if payment else 0 - logger.info(f"Checking Monero balance for account {account_index}") - balance_result = self.crypto_client._call( - "get_balance", {"account_index": account_index} - ) - unlocked_balance = balance_result.get("unlocked_balance", 0) - total_balance = balance_result.get("balance", 0) - logger.info( - f"Account {account_index} balance - Total: {total_balance} piconero ({Decimal(total_balance) / Decimal('1e12')} XMR)" - ) - logger.info( - f"Account {account_index} balance - Unlocked: {unlocked_balance} piconero ({Decimal(unlocked_balance) / Decimal('1e12')} XMR)" - ) - - if unlocked_balance < refund_amount_atomic: - logger.warning( - f"Insufficient unlocked balance in account {account_index}: need {refund_amount_atomic} but only have {unlocked_balance} unlocked" - ) - elif coin_type == "DOGE": - logger.info("Checking Dogecoin wallet balance") + # Get wallet balance for fee calculations + balance_result = None + if coin_type == "DOGE": + try: balance_result = self.crypto_client.getbalance() - logger.info(f"Dogecoin wallet balance: {balance_result} DOGE") - - - if balance_result < refund_amount_coin: - logger.warning( - f"Insufficient Dogecoin balance: need {refund_amount_coin} DOGE but only have {balance_result} DOGE" - ) - except Exception as balance_error: - logger.warning(f"Could not check wallet balance: {balance_error}") + except Exception: + pass try: # Create coin-specific refund transaction @@ -279,6 +243,8 @@ class PaymentRescue: 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 = [ { @@ -297,18 +263,6 @@ class PaymentRescue: "amount": shop_amount_atomic, }) - if payment_amount_atomic > 0: - logger.info( - f"Monero multi-output transfer: " - f"{refund_amount_atomic} piconero to customer, " - f"{shop_amount_atomic} piconero to shop (payment: {payment_amount_atomic} + fee: {fee_amount_atomic})" - ) - else: - logger.info( - f"Monero multi-output transfer: " - f"{refund_amount_atomic} piconero to customer, " - f"{fee_amount_atomic} piconero to shop" - ) transfer_params = { "destinations": destinations, @@ -317,7 +271,6 @@ class PaymentRescue: "do_not_relay": False, "priority": 1, } - logger.info(f"Calling Monero transfer with params: {transfer_params}") tx_result = self.crypto_client._call("transfer", transfer_params) elif coin_type == "DOGE": @@ -341,127 +294,46 @@ class PaymentRescue: # 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 - # Use conservative fee estimate to ensure we have enough room - # This accounts for fee calculation differences and rounding - estimated_fee = 0.01 # Very conservative to avoid insufficient funds errors + estimated_fee = 0.005 # Reasonable fee for 1-input, 2-output transaction # Check if we need to adjust for fees - if total_output + estimated_fee > balance_result: + if balance_result and total_output + estimated_fee > balance_result: # Calculate how much we need to reduce shortage = (total_output + estimated_fee) - balance_result - logger.info(f"Need to reduce outputs by {shortage} DOGE to account for network fee") # Reduce both amounts proportionally refund_ratio = refund_amount_doge / total_output shop_ratio = shop_amount_doge / total_output - # Reduce and round conservatively (round down to avoid exceeding balance) + # Round down to 3 decimal places import math - refund_reduction = shortage * refund_ratio - shop_reduction = shortage * shop_ratio - - # Round down to 3 decimal places for cleaner amounts - refund_amount_doge = math.floor((refund_amount_doge - refund_reduction) * 1000) / 1000 - shop_amount_doge = math.floor((shop_amount_doge - shop_reduction) * 1000) / 1000 - - logger.info(f"Adjusted amounts - Customer: {refund_amount_doge} DOGE, Shop: {shop_amount_doge} DOGE") + 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 - if payment_amount_doge > 0: - logger.info( - f"Calling Dogecoin sendmany with multi-output: " - f"{refund_amount_doge} DOGE to customer, " - f"{shop_amount_doge} DOGE to shop (payment: {payment_amount_doge} + fee: {fee_amount_doge})" - ) - else: - logger.info( - f"Calling Dogecoin sendmany with multi-output: " - f"{refund_amount_doge} DOGE to customer, " - f"{fee_amount_doge} DOGE to shop" - ) - # First check if we have enough balance including fees - # Already accounted for in shop_amount_doge adjustment above - total_needed = refund_amount_doge + shop_amount_doge - - if balance_result < total_needed: - logger.error( - f"Insufficient balance for refund: " - f"have {balance_result} DOGE, need {total_needed} DOGE" - ) - raise ValueError(f"Insufficient balance: have {balance_result}, need {total_needed}") - - # Get the account label for the payment address - # First, try to get account from the address + # Get the account for the payment address from_account = "" try: - # Try newer method first (getaddressinfo) - addr_info = self.crypto_client._call("getaddressinfo", [payment.address]) - if addr_info and "labels" in addr_info and addr_info["labels"]: - from_account = addr_info["labels"][0] - logger.info(f"Using account label from getaddressinfo: {from_account}") + 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()} + + # Try sendmany with the account that has the funds + try: + tx_hash = self.crypto_client.sendmany(from_account, clean_outputs, 1) except Exception as e: - # Fall back to older method (getaccount) - try: - from_account = self.crypto_client._call("getaccount", [payment.address]) - if from_account: - logger.info(f"Using account from getaccount: {from_account}") - except Exception as e2: - # Last resort: try to find in unspent outputs - try: - unspent = self.crypto_client._call("listunspent", []) - for utxo in unspent: - if utxo.get("address") == payment.address: - from_account = utxo.get("account", "") - if from_account: - logger.info(f"Using account from unspent output: {from_account}") - break - except Exception as e3: - logger.warning(f"Could not get account for address: {e}, {e2}, {e3}") - from_account = "" - - # sendmany(fromaccount, {address:amount,...}, minconf=1) - # Don't include comment - it causes issues with many Dogecoin versions - - # Ensure outputs are properly formatted - sometimes decimal precision issues cause 500 errors - # Convert outputs to ensure they're plain floats, not Decimal objects - clean_outputs = {} - for addr, amount in outputs.items(): - clean_outputs[addr] = float(amount) - - # If account has special characters, try different approaches - logger.info(f"Attempting sendmany with account='{from_account}', outputs={clean_outputs}") - - # Try different sendmany approaches until one works - tx_hash = None - attempts = [ - # 1. Try with the actual account first (where the funds are) - ("actual account", lambda: self.crypto_client.sendmany(from_account, clean_outputs, 1)), - # 2. Try with empty account (default) - ("empty account", lambda: self.crypto_client.sendmany("", clean_outputs, 1)), - # 3. Try actual account without minconf - ("actual account without minconf", lambda: self.crypto_client.sendmany(from_account, clean_outputs)), - # 4. Try with explicit default account "*" - ("default account '*'", lambda: self.crypto_client.sendmany("*", clean_outputs, 1)), - ] - - last_error = None - for attempt_name, attempt_func in attempts: - try: - logger.info(f"Trying sendmany with {attempt_name}") - tx_hash = attempt_func() - logger.info(f"Success with {attempt_name}: {tx_hash}") - break - except Exception as e: - last_error = e - logger.warning(f"Failed with {attempt_name}: {e}") - continue - - if not tx_hash and last_error: - raise last_error + # If that fails, try with empty account (default) + if "insufficient funds" in str(e).lower(): + tx_hash = self.crypto_client.sendmany("", clean_outputs, 1) + else: + raise tx_result = {"tx_hash": tx_hash} diff --git a/make_post_sell/tests/test_multi_output_refunds.py b/make_post_sell/tests/test_multi_output_refunds.py index 0a0debd..34f52e9 100644 --- a/make_post_sell/tests/test_multi_output_refunds.py +++ b/make_post_sell/tests/test_multi_output_refunds.py @@ -50,24 +50,25 @@ class TestMultiOutputRefunds(unittest.TestCase): "reason": "Test overpayment refund" } - # Mock the sendmany call - self.mock_client._call.return_value = "test_tx_hash_123" + # Mock the sendmany call and getbalance + self.mock_client.sendmany.return_value = "test_tx_hash_123" + self.mock_client.getbalance.return_value = 100.0 # Sufficient balance + self.mock_client._call.return_value = "" # For getaccount # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) # Verify multi-output sendmany was called - self.mock_client._call.assert_called_once() - call_args = self.mock_client._call.call_args[0] + self.mock_client.sendmany.assert_called_once() + call_args = self.mock_client.sendmany.call_args[0] - self.assertEqual(call_args[0], "sendmany") - self.assertEqual(call_args[1][0], "") # fromaccount + self.assertEqual(call_args[0], "") # fromaccount # Check outputs - outputs = call_args[1][1] + outputs = call_args[1] self.assertEqual(len(outputs), 2) # Two outputs - self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=8) - self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=8) + self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=2) + self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=2) # Verify result self.assertTrue(result["success"]) @@ -95,12 +96,13 @@ class TestMultiOutputRefunds(unittest.TestCase): } # Mock the RPC calls - self.mock_client._call.side_effect = [ - # get_balance call - {"balance": 2000000000000, "unlocked_balance": 2000000000000}, - # transfer call - {"tx_hash": "xmr_test_tx_hash_456"} - ] + def mock_call(method, params=None): + if method == "transfer": + return {"tx_hash": "xmr_test_tx_hash_456"} + else: + raise ValueError(f"Unexpected method: {method}") + + self.mock_client._call.side_effect = mock_call # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) @@ -177,17 +179,19 @@ class TestMultiOutputRefunds(unittest.TestCase): } # Mock the sendmany call - self.mock_client._call.return_value = "zero_fee_tx_123" + self.mock_client.sendmany.return_value = "zero_fee_tx_123" + self.mock_client.getbalance.return_value = 100.0 + self.mock_client._call.return_value = "" # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) # Verify multi-output sendmany was still used - self.mock_client._call.assert_called_once() - call_args = self.mock_client._call.call_args[0] - self.assertEqual(call_args[0], "sendmany") - outputs = call_args[1][1] - self.assertEqual(outputs["DCustomerRefundAddress123"], 100.0) # Full refund + self.mock_client.sendmany.assert_called_once() + call_args = self.mock_client.sendmany.call_args[0] + outputs = call_args[1] + # With 0.005 fee buffer, customer gets slightly less + self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 99.995, places=3) self.assertEqual(outputs["DShopSweepAddressTest123"], 0.0) # Zero fee # Verify result From 8836fb63332d2e01887fe7dd9d7e27b8f5208418 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 16:34:16 -0400 Subject: [PATCH 03/11] Add better fee error handling and monitoring - Log clear error messages when fee buffer is too low - Show exact shortfall amount and required action - Add DOGE_REFUND_FEE_BUFFER environment variable for dynamic adjustment - Log actual fee buffer used on successful refunds - Add helpful comment in vars.sh about the fee buffer setting Now when watching crypto-watcher logs, admins will see: - 'INSUFFICIENT FUNDS - Fee estimate too low\!' - Current buffer, shortfall amount, and line number to fix - Success messages show actual vs estimated fee usage This makes it much easier to adjust the fee buffer without diving into code. --- .../crypto_watcher/crypto_payment_rescue.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index d39fce8..99d27c0 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -294,7 +294,11 @@ class PaymentRescue: # 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 - estimated_fee = 0.005 # Reasonable fee for 1-input, 2-output transaction + + # Fee estimate - can be overridden via environment variable + import os + estimated_fee = float(os.environ.get('DOGE_REFUND_FEE_BUFFER', '0.005')) + logger.debug(f"Using fee buffer: {estimated_fee} DOGE") # Check if we need to adjust for fees if balance_result and total_output + estimated_fee > balance_result: @@ -329,13 +333,34 @@ class PaymentRescue: try: tx_hash = self.crypto_client.sendmany(from_account, clean_outputs, 1) except Exception as e: - # If that fails, try with empty account (default) - if "insufficient funds" in str(e).lower(): - tx_hash = self.crypto_client.sendmany("", clean_outputs, 1) + 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"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 estimated_fee in crypto_payment_rescue.py line ~318") + + # 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!") + 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})") else: raise ValueError(f"Refund not supported for coin type: {coin_type}") From 3289702d5015035c22880adb78a9884c2f776a3f Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 16:35:24 -0400 Subject: [PATCH 04/11] Move Dogecoin fee buffer to global constant for easier ops access - DOGE_REFUND_FEE_BUFFER now defined at top of file (line 22) - Clear comments explaining when/why to adjust it - Error messages point directly to the constant - Still overridable via environment variable --- .../lib/crypto_watcher/crypto_payment_rescue.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index 99d27c0..e2e4c6c 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -16,6 +16,11 @@ logger = logging.getLogger(__name__) RESTOCKING_FEE_PERCENT = Decimal("0.09") # 9% restocking fee OVERPAYMENT_THRESHOLD_PERCENT = Decimal("0.05") # 5% overpayment allowed before refund +# Dogecoin fee buffer - increase if you see "INSUFFICIENT FUNDS" errors in logs +# This accounts for network fees that Dogecoin adds on top of outputs +# Actual fees are typically 0.001-0.003 DOGE for 2-output transactions +DOGE_REFUND_FEE_BUFFER = 0.005 # Conservative buffer to avoid insufficient funds + def calculate_refund_amount(amount, fee_percent=RESTOCKING_FEE_PERCENT): """Calculate refund amount after deducting restocking fee.""" @@ -297,8 +302,9 @@ class PaymentRescue: # Fee estimate - can be overridden via environment variable import os - estimated_fee = float(os.environ.get('DOGE_REFUND_FEE_BUFFER', '0.005')) - logger.debug(f"Using fee buffer: {estimated_fee} DOGE") + 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") # Check if we need to adjust for fees if balance_result and total_output + estimated_fee > balance_result: @@ -342,7 +348,7 @@ class PaymentRescue: 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 estimated_fee in crypto_payment_rescue.py line ~318") + 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: From dc092d920ddcffc1bb8745b971487f08e8c97fac Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 16:42:44 -0400 Subject: [PATCH 05/11] Fix redirect after purchase to check for digital products properly - Add check for is_physical=False in addition to has_product_file - Add detailed logging to diagnose redirect issues - Physical products should always redirect to invoice - Digital products without files should redirect to invoice - Only digital products with files should redirect to product page --- make_post_sell/views/cart.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index ae6b245..06ae3a9 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -48,15 +48,34 @@ def get_smart_purchase_redirect_url(invoices): - Invoice URL if multiple products or physical products - 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") # Single digital product - redirect to download page - if len(line_items) == 1 and line_items[0].product.has_product_file: + if len(line_items) == 1: product = line_items[0].product - return f"/p/{product.id}/{product.slug}" + logger.info(f"Single product: {product.title} (ID: {product.id})") + 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}" + logger.info(f"Redirecting to product page: {redirect_url}") + return redirect_url + else: + if product.is_physical: + logger.info(f"Product is physical, redirecting to invoice") + else: + logger.info(f"Digital product has no file, redirecting to invoice") # Multiple products or physical products - redirect to invoice return f"/invoice/{invoice.id}" From a74bf72e6a04f9ce76a0014bbf1367d7f1f40e95 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 19:04:13 -0400 Subject: [PATCH 06/11] 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. --- make_post_sell/lib/crypto_watcher/__init__.py | 139 +++++++++++++++++- .../crypto_watcher/crypto_payment_rescue.py | 120 +++++++++------ make_post_sell/models/crypto_payment.py | 3 + ...add_swept_confirmations_to_track_sweep_.py | 30 ++++ make_post_sell/views/cart.py | 11 +- 5 files changed, 252 insertions(+), 51 deletions(-) create mode 100644 make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 1890efd..fe6bd32 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -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) diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index e2e4c6c..bc60a63 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -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}") diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index b7905bd..b869f8f 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -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( diff --git a/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py new file mode 100644 index 0000000..02c075f --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py @@ -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') diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index 06ae3a9..7207da6 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -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}" From d90f41c5d9eeab960075219008dbcd6a6fdea527 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 19:15:09 -0400 Subject: [PATCH 07/11] Fix sweep confirmation monitoring to use coin-specific thresholds - Query DOGE sweeps only if < 2 confirmations - Query XMR sweeps only if < 10 confirmations - Set model default to 0 for new sweeps - Migration sets server_default to 10 for existing records - This prevents monitoring already-confirmed legacy sweeps --- make_post_sell/lib/crypto_watcher/__init__.py | 30 +++++++++++-------- ...add_swept_confirmations_to_track_sweep_.py | 2 +- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index fe6bd32..434e3be 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -3357,19 +3357,25 @@ def process_sweep_confirmations(request, settings): 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), + sweep_queue = [] + + # Query each coin type with its specific threshold + for coin_type, required_confirmations in OUTBOUND_CONFIRMATIONS_REQUIRED.items(): + coin_sweeps = ( + db.query(CryptoPayment) + .options( + sa.orm.joinedload(CryptoPayment.user), + sa.orm.joinedload(CryptoPayment.shop), + ) + .filter( + CryptoPayment.status == CryptoPayment.STATUS_CONFIRMED, + CryptoPayment.coin_type == coin_type, + CryptoPayment.swept_tx_hash != None, + CryptoPayment.swept_confirmations < required_confirmations, + ) + .all() ) - .filter( - CryptoPayment.status == CryptoPayment.STATUS_CONFIRMED, - CryptoPayment.swept_tx_hash != None, - CryptoPayment.swept_confirmations < 10, # Max threshold - ) - .all() - ) + sweep_queue.extend(coin_sweeps) if not sweep_queue: log.processing_cycle("No sweep transactions need confirmation monitoring") diff --git a/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py index 02c075f..45a1ce7 100644 --- a/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py +++ b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py @@ -21,7 +21,7 @@ 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') + sa.Column('swept_confirmations', sa.Integer(), nullable=False, server_default='10') ) From f545e0e3e4e9565f99edfe7d4d781c4630dbc4c0 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 19:22:38 -0400 Subject: [PATCH 08/11] modified: make_post_sell/lib/crypto_watcher/__init__.py modified: make_post_sell/lib/crypto_watcher/crypto_clients.py modified: make_post_sell/models/invoice.py modified: make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py modified: make_post_sell/tests/test_crypto_watcher.py modified: make_post_sell/tests/test_multi_output_refunds.py modified: make_post_sell/views/crypto.py --- make_post_sell/lib/crypto_watcher/__init__.py | 2 +- .../lib/crypto_watcher/crypto_clients.py | 10 ++- make_post_sell/models/invoice.py | 7 +- ...add_swept_confirmations_to_track_sweep_.py | 14 +-- make_post_sell/tests/test_crypto_watcher.py | 22 +++-- .../tests/test_multi_output_refunds.py | 85 ++++++++++--------- make_post_sell/views/crypto.py | 31 +++++-- 7 files changed, 109 insertions(+), 62 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 434e3be..14a4fce 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -3358,7 +3358,7 @@ def process_sweep_confirmations(request, settings): # Query payments that need sweep confirmation monitoring sweep_queue = [] - + # Query each coin type with its specific threshold for coin_type, required_confirmations in OUTBOUND_CONFIRMATIONS_REQUIRED.items(): coin_sweeps = ( diff --git a/make_post_sell/lib/crypto_watcher/crypto_clients.py b/make_post_sell/lib/crypto_watcher/crypto_clients.py index cc7b3c6..1c24e9b 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_clients.py +++ b/make_post_sell/lib/crypto_watcher/crypto_clients.py @@ -300,9 +300,15 @@ class DogecoinClient: """Send Dogecoin to an address. Returns transaction ID.""" return self._call("sendtoaddress", [address, amount, comment]) - def sendmany(self, from_label: str, addresses_amounts: Dict[str, float], minconf: int = 1, comment: str = "") -> str: + def sendmany( + self, + from_label: str, + addresses_amounts: Dict[str, float], + minconf: int = 1, + comment: str = "", + ) -> str: """Send to multiple addresses at once. More efficient for sweeping. - + Args: from_label: Account label (use "" for default account) addresses_amounts: Dict mapping addresses to amounts diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index eb499d0..2e96fa2 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -288,7 +288,12 @@ class Invoice(RBase, Base): @property def is_paid(self): """Check if this invoice has been successfully paid.""" - return self.payment_status in ["confirmed", "confirmed-overpay", "confirmed-complete", "paid"] + return self.payment_status in [ + "confirmed", + "confirmed-overpay", + "confirmed-complete", + "paid", + ] def get_invoice_by_id(dbsession, invoice_id): diff --git a/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py index 45a1ce7..52853a2 100644 --- a/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py +++ b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py @@ -5,13 +5,14 @@ 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' +revision = "0915b3ff883d" +down_revision = "07908c8c840d" branch_labels = None depends_on = None @@ -20,11 +21,14 @@ 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='10') + op.add_column( + "mps_crypto_payment", + sa.Column( + "swept_confirmations", sa.Integer(), nullable=False, server_default="10" + ), ) def downgrade(): # Remove swept_confirmations column from mps_crypto_payment table - op.drop_column('mps_crypto_payment', 'swept_confirmations') + op.drop_column("mps_crypto_payment", "swept_confirmations") diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index 23f5509..4b6cc4e 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -1230,7 +1230,9 @@ class AutoSweepTests(unittest.TestCase): # Verify that swept_tx_hash was set (from transfer, not sweep) self.assertEqual(payment.swept_tx_hash, "transfer_tx_123") - self.assertEqual(payment.swept_amount, 494500000000) # 0.5 XMR - (0.005 XMR fee * 1.1 margin) + self.assertEqual( + payment.swept_amount, 494500000000 + ) # 0.5 XMR - (0.005 XMR fee * 1.1 margin) self.assertEqual(payment.swept_network_fee, 5000000000) self.assertIsNotNone(payment.swept_timestamp) @@ -3113,7 +3115,9 @@ class RefundTypeTests(unittest.TestCase): doge_payment.coin_type = "DOGE" doge_payment.current_confirmations = 2 # Sufficient confirmations doge_payment.account_index = None # DOGE doesn't use account_index - doge_payment.shop_sweep_to_address = "DShopSweepAddress789" # Shop address for fee + doge_payment.shop_sweep_to_address = ( + "DShopSweepAddress789" # Shop address for fee + ) mock_client = MagicMock() mock_client._call.return_value = "doge-tx-hash-123" @@ -3158,7 +3162,9 @@ class RefundTypeTests(unittest.TestCase): xmr_payment.coin_type = "XMR" xmr_payment.current_confirmations = 10 # Sufficient confirmations xmr_payment.account_index = 5 - xmr_payment.shop_sweep_to_address = "4ShopSweepAddressXMR789" # Shop address for fee + xmr_payment.shop_sweep_to_address = ( + "4ShopSweepAddressXMR789" # Shop address for fee + ) mock_client = MagicMock() mock_client._call.side_effect = [ @@ -3335,15 +3341,19 @@ class RefundTypeTests(unittest.TestCase): # Verify successful result self.assertTrue(result["success"]) self.assertEqual(result["tx_hash"], "doge-precision-tx-123") - + # Verify sendmany was called with properly rounded amounts mock_client._call.assert_called_once() call_args = mock_client._call.call_args[0] self.assertEqual(call_args[0], "sendmany") outputs = call_args[1][1] # Verify amounts are rounded to 8 decimal places - self.assertEqual(outputs["DPrecisionTest123"], 1.03859035) # Rounded from 1.0385903528 - self.assertEqual(outputs["DShopPrecisionAddress"], 0.11429455) # Rounded from 0.1142945472 + self.assertEqual( + outputs["DPrecisionTest123"], 1.03859035 + ) # Rounded from 1.0385903528 + self.assertEqual( + outputs["DShopPrecisionAddress"], 0.11429455 + ) # Rounded from 0.1142945472 def test_xmr_refund_amount_precision_handling(self): """Test XMR refund execution handles high precision amounts correctly.""" diff --git a/make_post_sell/tests/test_multi_output_refunds.py b/make_post_sell/tests/test_multi_output_refunds.py index 34f52e9..ce552e7 100644 --- a/make_post_sell/tests/test_multi_output_refunds.py +++ b/make_post_sell/tests/test_multi_output_refunds.py @@ -11,7 +11,10 @@ from unittest.mock import MagicMock, patch from decimal import Decimal import uuid -from ..lib.crypto_watcher.crypto_payment_rescue import PaymentRescue, RESTOCKING_FEE_PERCENT +from ..lib.crypto_watcher.crypto_payment_rescue import ( + PaymentRescue, + RESTOCKING_FEE_PERCENT, +) from ..models.crypto_payment import CryptoPayment @@ -23,7 +26,7 @@ class TestMultiOutputRefunds(unittest.TestCase): self.mock_dbsession = MagicMock() self.mock_client = MagicMock() self.rescue = PaymentRescue(self.mock_dbsession, self.mock_client) - + # Create a mock payment with shop sweep address self.payment = MagicMock(spec=CryptoPayment) self.payment.id = uuid.uuid4() @@ -39,7 +42,7 @@ class TestMultiOutputRefunds(unittest.TestCase): received_amount = Decimal("100.0") # 100 DOGE received fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 9 DOGE fee refund_amount = received_amount - fee_amount # 91 DOGE refund - + refund_details = { "type": "overpayment", "payment_id": self.payment.id, @@ -47,29 +50,29 @@ class TestMultiOutputRefunds(unittest.TestCase): "received_amount": received_amount, "refund_amount": refund_amount, "fee_amount": fee_amount, - "reason": "Test overpayment refund" + "reason": "Test overpayment refund", } - + # Mock the sendmany call and getbalance self.mock_client.sendmany.return_value = "test_tx_hash_123" self.mock_client.getbalance.return_value = 100.0 # Sufficient balance self.mock_client._call.return_value = "" # For getaccount - + # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) - + # Verify multi-output sendmany was called self.mock_client.sendmany.assert_called_once() call_args = self.mock_client.sendmany.call_args[0] - + self.assertEqual(call_args[0], "") # fromaccount - + # Check outputs outputs = call_args[1] self.assertEqual(len(outputs), 2) # Two outputs self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=2) self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=2) - + # Verify result self.assertTrue(result["success"]) self.assertEqual(result["tx_hash"], "test_tx_hash_123") @@ -79,12 +82,12 @@ class TestMultiOutputRefunds(unittest.TestCase): """Test XMR refund with multi-output (customer + shop).""" # Set up payment for XMR self.payment.coin_type = "XMR" - + # Set up refund details received_amount = Decimal("1.0") # 1 XMR received fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 0.09 XMR fee refund_amount = received_amount - fee_amount # 0.91 XMR refund - + refund_details = { "type": "overpayment", "payment_id": self.payment.id, @@ -92,43 +95,45 @@ class TestMultiOutputRefunds(unittest.TestCase): "received_amount": received_amount, "refund_amount": refund_amount, "fee_amount": fee_amount, - "reason": "Test overpayment refund" + "reason": "Test overpayment refund", } - + # Mock the RPC calls def mock_call(method, params=None): if method == "transfer": return {"tx_hash": "xmr_test_tx_hash_456"} else: raise ValueError(f"Unexpected method: {method}") - + self.mock_client._call.side_effect = mock_call - + # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) - + # Find the transfer call transfer_call = None for call in self.mock_client._call.call_args_list: if call[0][0] == "transfer": transfer_call = call break - + self.assertIsNotNone(transfer_call) transfer_params = transfer_call[0][1] - + # Check destinations destinations = transfer_params["destinations"] self.assertEqual(len(destinations), 2) # Two destinations - + # Customer refund destination self.assertEqual(destinations[0]["address"], "4CustomerRefundAddressXMR123") - self.assertEqual(destinations[0]["amount"], 910000000000) # 0.91 XMR in piconero - + self.assertEqual( + destinations[0]["amount"], 910000000000 + ) # 0.91 XMR in piconero + # Shop fee destination self.assertEqual(destinations[1]["address"], "DShopSweepAddressTest123") self.assertEqual(destinations[1]["amount"], 90000000000) # 0.09 XMR in piconero - + # Verify result self.assertTrue(result["success"]) self.assertEqual(result["tx_hash"], "xmr_test_tx_hash_456") @@ -138,12 +143,12 @@ class TestMultiOutputRefunds(unittest.TestCase): """Test refund fails when shop has no sweep address.""" # Remove shop sweep address self.payment.shop_sweep_to_address = None - + # Set up refund details received_amount = Decimal("100.0") fee_amount = received_amount * RESTOCKING_FEE_PERCENT refund_amount = received_amount - fee_amount - + refund_details = { "type": "overpayment", "payment_id": self.payment.id, @@ -151,16 +156,16 @@ class TestMultiOutputRefunds(unittest.TestCase): "received_amount": received_amount, "refund_amount": refund_amount, "fee_amount": fee_amount, - "reason": "Test overpayment refund" + "reason": "Test overpayment refund", } - + # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) - + # Verify refund failed self.assertFalse(result["success"]) self.assertIn("Shop sweep address is required", result["error"]) - + # Verify no transaction was attempted self.mock_client._call.assert_not_called() self.mock_client.sendtoaddress.assert_not_called() @@ -175,17 +180,17 @@ class TestMultiOutputRefunds(unittest.TestCase): "received_amount": Decimal("100.0"), "refund_amount": Decimal("100.0"), "fee_amount": Decimal("0"), - "reason": "Test refund with zero fee" + "reason": "Test refund with zero fee", } - + # Mock the sendmany call self.mock_client.sendmany.return_value = "zero_fee_tx_123" self.mock_client.getbalance.return_value = 100.0 self.mock_client._call.return_value = "" - + # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) - + # Verify multi-output sendmany was still used self.mock_client.sendmany.assert_called_once() call_args = self.mock_client.sendmany.call_args[0] @@ -193,7 +198,7 @@ class TestMultiOutputRefunds(unittest.TestCase): # With 0.005 fee buffer, customer gets slightly less self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 99.995, places=3) self.assertEqual(outputs["DShopSweepAddressTest123"], 0.0) # Zero fee - + # Verify result self.assertTrue(result["success"]) self.assertEqual(result["tx_hash"], "zero_fee_tx_123") @@ -202,7 +207,7 @@ class TestMultiOutputRefunds(unittest.TestCase): """Test refund is delayed when insufficient confirmations.""" # Set insufficient confirmations self.payment.current_confirmations = 1 - + refund_details = { "type": "overpayment", "payment_id": self.payment.id, @@ -210,21 +215,21 @@ class TestMultiOutputRefunds(unittest.TestCase): "received_amount": Decimal("100.0"), "refund_amount": Decimal("91.0"), "fee_amount": Decimal("9.0"), - "reason": "Test refund" + "reason": "Test refund", } - + # Execute refund result = self.rescue.execute_refund(refund_details, self.payment) - + # Verify refund was delayed self.assertFalse(result["success"]) self.assertIn("confirmations", result["error"]) self.assertEqual(result["confirmations_needed"], 1) # Need 1 more confirmation - + # Verify no transaction was attempted self.mock_client._call.assert_not_called() self.mock_client.sendtoaddress.assert_not_called() if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index 7ef1d52..c90149f 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -872,7 +872,8 @@ def crypto_xmr_status(request): # Add smart redirect URL when payment is confirmed (any confirmed status) if ( - crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"] + crypto_payment.status + in ["confirmed", "confirmed-complete", "confirmed-overpay"] and crypto_payment.invoice ): from ..views.cart import get_smart_purchase_redirect_url @@ -971,7 +972,8 @@ def crypto_doge_status(request): # Add smart redirect URL when payment is confirmed (any confirmed status) if ( - crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"] + crypto_payment.status + in ["confirmed", "confirmed-complete", "confirmed-overpay"] and crypto_payment.invoice ): from ..views.cart import get_smart_purchase_redirect_url @@ -1244,11 +1246,26 @@ def get_payment_status_info(status): "label": "✓ Duplicate Payment - Refunded", "color": "#fd7e14", }, - "latepay-not-refunded": {"label": "Late Payment - No Refund", "color": "#dc3545"}, - "underpaid-not-refunded": {"label": "Underpaid - No Refund", "color": "#dc3545"}, - "confirmed-overpay-not-refunded": {"label": "Overpaid - No Refund", "color": "#dc3545"}, - "out-of-stock-not-refunded": {"label": "Out of Stock - No Refund", "color": "#dc3545"}, - "doublepay-not-refunded": {"label": "Duplicate Payment - No Refund", "color": "#dc3545"}, + "latepay-not-refunded": { + "label": "Late Payment - No Refund", + "color": "#dc3545", + }, + "underpaid-not-refunded": { + "label": "Underpaid - No Refund", + "color": "#dc3545", + }, + "confirmed-overpay-not-refunded": { + "label": "Overpaid - No Refund", + "color": "#dc3545", + }, + "out-of-stock-not-refunded": { + "label": "Out of Stock - No Refund", + "color": "#dc3545", + }, + "doublepay-not-refunded": { + "label": "Duplicate Payment - No Refund", + "color": "#dc3545", + }, } return status_mapping.get(status, {"label": status.title(), "color": "#6c757d"}) From 24bd698ecdc61ced726725d32741e82deec214d9 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 19:26:53 -0400 Subject: [PATCH 09/11] Fix DOGE sweep insufficient funds error using subtractfeefromamount - Use subtractfeefromamount=True parameter in sendtoaddress for DOGE sweeps - This allows Dogecoin to automatically deduct network fee from the sweep amount - Resolves 500 errors when trying to sweep the exact wallet balance - Regular payment sweeps remain single-output transactions (no multi-output needed) --- make_post_sell/lib/crypto_watcher/__init__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 14a4fce..17b5e97 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -1094,10 +1094,16 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non ) # Send the sweep transaction - tx_hash = client.sendtoaddress( - crypto_payment.shop_sweep_to_address, - float(sweep_amount), - f"Sweep for invoice {crypto_payment.invoice.id}", + # Use subtractfeefromamount=True so network fee is deducted from the sweep amount + tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.shop_sweep_to_address, + float(sweep_amount), + f"Sweep for invoice {crypto_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) if tx_hash: From 2657f982ecaf9b2a6877415e6d428924b7f2cd6e Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 19:55:27 -0400 Subject: [PATCH 10/11] Fix crypto_watcher tests after sendtoaddress changes - Update DOGE sweep tests to expect _call method with subtractfeefromamount - Fix refund tests to properly mock sendmany method calls - Update sweep_restocking_fee to use consistent _call interface - Adjust tests to account for estimatesmartfee calls before sweeps - Update XMR refund tests to match actual implementation flow All 101 crypto_watcher tests now pass successfully. --- make_post_sell/lib/crypto_watcher/__init__.py | 11 +- make_post_sell/tests/test_crypto_watcher.py | 229 ++++++++++++------ 2 files changed, 166 insertions(+), 74 deletions(-) diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 17b5e97..680c420 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -549,8 +549,15 @@ def sweep_restocking_fee(settings, payment, refund_details, dbsession, context=" amount_to_send_crypto = float( Decimal(amount_to_send_atomic) / atomic_units ) - fee_tx_hash = client.sendtoaddress( - payment.shop_sweep_to_address, amount_to_send_crypto + fee_tx_hash = client._call( + "sendtoaddress", + [ + payment.shop_sweep_to_address, + amount_to_send_crypto, + "", # comment + "", # comment_to + True, # subtractfeefromamount + ], ) actual_swept = amount_to_send_atomic log.sweep_operation( diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index 4b6cc4e..816537f 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -1357,7 +1357,7 @@ class DogecoinWatcherUnitTests(unittest.TestCase): """Test successful Dogecoin auto-sweep.""" mock_client = MagicMock() mock_client.getbalance.return_value = 100.5 - mock_client.sendtoaddress.return_value = "sweep_tx_hash_123" + mock_client._call.return_value = "sweep_tx_hash_123" payment = MagicMock() payment.id = "payment_123" @@ -1371,11 +1371,28 @@ class DogecoinWatcherUnitTests(unittest.TestCase): result = auto_sweep_payment_doge(mock_client, payment) self.assertTrue(result) - mock_client.sendtoaddress.assert_called_once_with( - "DColdWalletAddress123", - 10.0, # Now sweeps only the expected amount (10 DOGE) - "Sweep for invoice invoice_123", + # Check that both estimatesmartfee and sendtoaddress were called + self.assertEqual(mock_client._call.call_count, 2) + + # First call should be estimatesmartfee + first_call = mock_client._call.call_args_list[0] + self.assertEqual(first_call[0][0], "estimatesmartfee") + self.assertEqual(first_call[0][1], [6]) # 6 block target + + # Second call should be sendtoaddress + second_call = mock_client._call.call_args_list[1] + self.assertEqual(second_call[0][0], "sendtoaddress") + self.assertEqual( + second_call[0][1], + [ + "DColdWalletAddress123", + 10.0, # Now sweeps only the expected amount (10 DOGE) + "Sweep for invoice invoice_123", + "", # comment_to + True, # subtractfeefromamount + ], ) + self.assertEqual(payment.swept_tx_hash, "sweep_tx_hash_123") def test_auto_sweep_payment_doge_no_address(self): @@ -1464,7 +1481,7 @@ class DogecoinWatcherUnitTests(unittest.TestCase): """Test that DOGE auto-sweep properly updates payment object in database session.""" mock_client = MagicMock() mock_client.getbalance.return_value = 10.0 # 10 DOGE - mock_client.sendtoaddress.return_value = "doge_sweep_tx_456" + mock_client._call.return_value = "doge_sweep_tx_456" # Create a mock dbsession mock_dbsession = MagicMock() @@ -2545,7 +2562,7 @@ class SweepRestockingFeeTests(unittest.TestCase): # Setup mock DOGE client mock_doge_client = MagicMock() mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance - mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" + mock_doge_client._call.return_value = "doge_tx_hash_123" mock_get_client.return_value = mock_doge_client # Execute sweep @@ -2559,8 +2576,16 @@ class SweepRestockingFeeTests(unittest.TestCase): # Verify client was called correctly mock_get_client.assert_called_once_with(self.mock_settings, "DOGE") - mock_doge_client.sendtoaddress.assert_called_once_with( - "DE2ET4uMRYMQ3nhtSjiTcbbopA3VNn1Ckh", 0.36 # fee_amount as float + # sweep_restocking_fee only calls sendtoaddress (no estimatesmartfee) + mock_doge_client._call.assert_called_once_with( + "sendtoaddress", + [ + "DE2ET4uMRYMQ3nhtSjiTcbbopA3VNn1Ckh", + 0.36, # fee_amount as float + "", # comment + "", # comment_to + True, # subtractfeefromamount + ], ) # Verify sleep was called (2 second delay) @@ -2720,7 +2745,7 @@ class SweepRestockingFeeTests(unittest.TestCase): # Setup mock DOGE client that throws exception mock_doge_client = MagicMock() mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance - mock_doge_client.sendtoaddress.side_effect = Exception("RPC connection error") + mock_doge_client._call.side_effect = Exception("RPC connection error") mock_get_client.return_value = mock_doge_client with patch("make_post_sell.lib.crypto_watcher.log") as mock_logger: @@ -2790,7 +2815,7 @@ class SweepRestockingFeeTests(unittest.TestCase): # Setup mock DOGE client mock_doge_client = MagicMock() mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance - mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" + mock_doge_client._call.return_value = "doge_tx_hash_123" mock_get_client.return_value = mock_doge_client with patch("make_post_sell.lib.crypto_watcher.log") as mock_logger: @@ -3120,8 +3145,9 @@ class RefundTypeTests(unittest.TestCase): ) mock_client = MagicMock() - mock_client._call.return_value = "doge-tx-hash-123" + mock_client.sendmany.return_value = "doge-tx-hash-123" mock_client.getbalance.return_value = 100.0 # Sufficient DOGE balance + mock_client._call.return_value = "" # For getaccount call rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3137,13 +3163,13 @@ class RefundTypeTests(unittest.TestCase): result = rescue.execute_refund(refund_details, doge_payment) # Verify sendmany was called with multi-output - mock_client._call.assert_called_once() - call_args = mock_client._call.call_args[0] - self.assertEqual(call_args[0], "sendmany") - self.assertEqual(call_args[1][0], "") # fromaccount - outputs = call_args[1][1] + mock_client.sendmany.assert_called_once() + call_args = mock_client.sendmany.call_args[0] + self.assertEqual(call_args[0], "") # fromaccount + outputs = call_args[1] self.assertEqual(outputs["DTestAddress123"], 4.55) # Customer refund self.assertEqual(outputs["DShopSweepAddress789"], 0.45) # Shop fee + self.assertEqual(call_args[2], 1) # minconf mock_client.getbalance.assert_called_once() # Balance check # Verify successful result @@ -3167,13 +3193,9 @@ class RefundTypeTests(unittest.TestCase): ) mock_client = MagicMock() - mock_client._call.side_effect = [ - { - "balance": 2000000000000, - "unlocked_balance": 1500000000000, - }, # Balance check - {"tx_hash": "xmr-tx-hash-456"}, # Transfer result - ] + mock_client._call.return_value = { + "tx_hash": "xmr-tx-hash-456" + } # Transfer result rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3188,12 +3210,8 @@ class RefundTypeTests(unittest.TestCase): result = rescue.execute_refund(refund_details, xmr_payment) - # Verify XMR-specific RPC calls - expected_balance_call = mock_client._call.call_args_list[0] - self.assertEqual(expected_balance_call[0][0], "get_balance") - self.assertEqual(expected_balance_call[0][1]["account_index"], 5) - - expected_transfer_call = mock_client._call.call_args_list[1] + # Verify XMR-specific RPC calls - only transfer, no balance check + expected_transfer_call = mock_client._call.call_args_list[0] self.assertEqual(expected_transfer_call[0][0], "transfer") transfer_params = expected_transfer_call[0][1] self.assertEqual(transfer_params["account_index"], 5) @@ -3319,8 +3337,9 @@ class RefundTypeTests(unittest.TestCase): doge_payment.shop_sweep_to_address = "DShopPrecisionAddress" mock_client = MagicMock() - mock_client._call.return_value = "doge-precision-tx-123" + mock_client.sendmany.return_value = "doge-precision-tx-123" mock_client.getbalance.return_value = 100.0 + mock_client._call.return_value = "" # For getaccount call rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3343,10 +3362,10 @@ class RefundTypeTests(unittest.TestCase): self.assertEqual(result["tx_hash"], "doge-precision-tx-123") # Verify sendmany was called with properly rounded amounts - mock_client._call.assert_called_once() - call_args = mock_client._call.call_args[0] - self.assertEqual(call_args[0], "sendmany") - outputs = call_args[1][1] + mock_client.sendmany.assert_called_once() + call_args = mock_client.sendmany.call_args[0] + self.assertEqual(call_args[0], "") # fromaccount + outputs = call_args[1] # Verify amounts are rounded to 8 decimal places self.assertEqual( outputs["DPrecisionTest123"], 1.03859035 @@ -3367,16 +3386,13 @@ class RefundTypeTests(unittest.TestCase): xmr_payment.coin_type = "XMR" xmr_payment.current_confirmations = 10 xmr_payment.account_index = 2 + xmr_payment.shop_sweep_to_address = ( + "4ShopPrecisionAddressXMR" # Required for fee destination + ) mock_client = MagicMock() mock_client._call.return_value = {"tx_hash": "xmr-precision-tx-123"} - # Mock balance check - mock_client._call.side_effect = [ - {"unlocked_balance": 100000000000000}, # get_balance call - {"tx_hash": "xmr-precision-tx-123"}, # transfer call - ] - rescue = PaymentRescue(self.mock_dbsession, mock_client) # Test with high precision amount (12 decimal places for XMR) @@ -3399,16 +3415,25 @@ class RefundTypeTests(unittest.TestCase): # 0.123456789123 XMR * 1e12 = 123456789123 piconero expected_atomic_amount = 123456789123 - # Check the transfer call (second call) - transfer_call = mock_client._call.call_args_list[1] + # Check the transfer call (first and only call) + transfer_call = mock_client._call.call_args_list[0] self.assertEqual(transfer_call[0][0], "transfer") # method transfer_params = transfer_call[0][1] # params + # Check refund destination self.assertEqual( transfer_params["destinations"][0]["amount"], expected_atomic_amount ) self.assertEqual( transfer_params["destinations"][0]["address"], "4XMRPrecisionTest123" ) + # Check fee destination + expected_fee_atomic = 13717354347 # 0.013717354347 XMR * 1e12 + self.assertEqual( + transfer_params["destinations"][1]["amount"], expected_fee_atomic + ) + self.assertEqual( + transfer_params["destinations"][1]["address"], "4ShopPrecisionAddressXMR" + ) self.assertEqual(transfer_params["account_index"], 2) @@ -3473,7 +3498,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock client self.mock_client = MagicMock() self.mock_client.getbalance.return_value = 20.0 # Sufficient DOGE balance - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-123" + self.mock_client._call.return_value = "sweep-tx-hash-123" @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @@ -3495,7 +3520,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock auto-sweep success self.mock_client.getbalance.return_value = 10.0 # Sufficient balance - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-123" + self.mock_client._call.return_value = "sweep-tx-hash-123" # No payment rescue (normal payment) result = process_confirmed_payment( @@ -3517,10 +3542,18 @@ class PaymentConfirmationOrderTests(unittest.TestCase): self.assertTrue(result["auto_sweep"]["success"]) # Verify auto-sweep called with correct amount (5 DOGE) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 5.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 5.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @@ -3570,7 +3603,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock auto-sweep success self.mock_client.getbalance.return_value = 10.0 - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-789" + self.mock_client._call.return_value = "sweep-tx-hash-789" result = process_confirmed_payment( self.mock_env_request, @@ -3595,10 +3628,18 @@ class PaymentConfirmationOrderTests(unittest.TestCase): mock_sweep_fee.assert_not_called() # 4) Auto-sweep invoice amount (4 DOGE, not entire balance) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 4.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 4.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) # Verify results @@ -3705,7 +3746,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock auto-sweep success self.mock_client.getbalance.return_value = 10.0 - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-789" + self.mock_client._call.return_value = "sweep-tx-hash-789" result = process_confirmed_payment( self.mock_env_request, @@ -3719,10 +3760,18 @@ class PaymentConfirmationOrderTests(unittest.TestCase): self.assertFalse(result["restocking_fee_swept"]) # Verify normal auto-sweep of invoice amount (4.0 DOGE) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 4.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 4.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) self.assertTrue(result["auto_sweep"]["success"]) from make_post_sell.models.crypto_payment import CryptoPayment @@ -3748,17 +3797,25 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Large wallet balance self.mock_client.getbalance.return_value = 25.75 # 25.75 DOGE total balance - self.mock_client.sendtoaddress.return_value = "amount-sweep-tx-hash" + self.mock_client._call.return_value = "amount-sweep-tx-hash" result = auto_sweep_payment( self.mock_client, self.mock_payment, self.mock_env_request.dbsession ) # Verify only expected amount was swept, not entire wallet - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 3.25, - "Sweep for invoice invoice-789", # Specific amount, not 25.75 + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 3.25, + "Sweep for invoice invoice-789", # Specific amount, not 25.75 + "", # comment_to + True, # subtractfeefromamount + ], ) self.assertTrue(result) @@ -4134,7 +4191,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): self.mock_payment.is_swept = False self.mock_client.getbalance.return_value = 50.12345 # Large DOGE balance - self.mock_client.sendtoaddress.return_value = "doge-precise-sweep-tx" + self.mock_client._call.return_value = "doge-precise-sweep-tx" # Sweep expected amount only (not entire wallet) doge_result = auto_sweep_payment( @@ -4142,8 +4199,23 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) # Verify precise DOGE amount (not entire wallet) - self.mock_client.sendtoaddress.assert_called_with( - "DShopSweepAddress123", 1.23456789, "Sweep for invoice invoice-789" + # Find the sendtoaddress call (should be after estimatesmartfee) + sendtoaddress_calls = [ + call + for call in self.mock_client._call.call_args_list + if call[0][0] == "sendtoaddress" + ] + self.assertTrue(sendtoaddress_calls) + last_send = sendtoaddress_calls[-1] + self.assertEqual( + last_send[0][1], + [ + "DShopSweepAddress123", + 1.23456789, + "Sweep for invoice invoice-789", + "", # comment_to + True, # subtractfeefromamount + ], ) self.assertTrue(doge_result) @@ -4374,7 +4446,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock successful refund on retry refund_success = {"success": True, "tx_hash": "retry-refund-tx-123"} mock_payment_rescue.execute_refund.return_value = refund_success - self.mock_client.sendtoaddress.return_value = "retry-sweep-tx-456" + self.mock_client._call.return_value = "retry-sweep-tx-456" # Second attempt - refund succeeds result2 = process_confirmed_payment( @@ -4394,10 +4466,23 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) # Verify auto-sweep happened (5 DOGE invoice amount) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 5.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Find the sendtoaddress call + sendtoaddress_calls = [ + call + for call in self.mock_client._call.call_args_list + if call[0][0] == "sendtoaddress" + ] + self.assertTrue(sendtoaddress_calls) + last_send = sendtoaddress_calls[-1] + self.assertEqual( + last_send[0][1], + [ + "DShopSweepAddress123", + 5.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) From 9432375bcbc8e32866ffa6468d0ed8d56b1b13ac Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 3 Oct 2025 09:46:20 -0400 Subject: [PATCH 11/11] Add shop ribbon color styling to links in product and content descriptions - Modify protect_links function to apply shop theme_link_color to all links - Add custom CSS validation using regex patterns for security - Support hex colors (#fff, #ffffff), rgb(), rgba(), hsl(), hsla(), and named colors - Reject malicious inputs like javascript: schemes - Add style attribute to allowed attributes for anchor tags - Pass shop reference through cleaner object for color access --- make_post_sell/lib/render.py | 2 ++ make_post_sell/lib/sanitize_html.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/make_post_sell/lib/render.py b/make_post_sell/lib/render.py index 58a7691..adb7f46 100644 --- a/make_post_sell/lib/render.py +++ b/make_post_sell/lib/render.py @@ -13,6 +13,8 @@ def make_cleaner_from_shop(shop): """Given a Shop return a bleach Cleaner object.""" cleaner = default_cleaner() cleaner.link_protection = True + # Store shop reference for link color styling + cleaner.shop = shop if shop.domain_name: apex_domain_name = shop.domain_name.split(".")[-2:] cleaner.whitelist_domains.append("makepostsell.com") diff --git a/make_post_sell/lib/sanitize_html.py b/make_post_sell/lib/sanitize_html.py index 71b6792..9533178 100644 --- a/make_post_sell/lib/sanitize_html.py +++ b/make_post_sell/lib/sanitize_html.py @@ -10,6 +10,8 @@ from bleach.callbacks import nofollow, target_blank from bleach_allowlist import markdown_tags, markdown_attrs, all_styles +# We implement our own CSS validation in protect_links() for security + from bs4 import BeautifulSoup import miniuri @@ -58,6 +60,11 @@ def default_cleaner(tag_acl=None): attrs["img"].append("width") attrs["img"].append("style") attrs["span"] = ["class"] + + # Allow style attribute on anchor tags for link color styling + if "a" not in attrs: + attrs["a"] = [] + attrs["a"].append("style") # Allow both whitelist and blacklist tag_name/attr/attr_value # to get past bleach. @@ -78,6 +85,8 @@ def default_cleaner(tag_acl=None): # attributes for the given tag_name. attrs[tag_name].append(attr_name) + # We allow style attributes and validate CSS in protect_links function + # This provides targeted validation for the specific CSS we add (color property) cleaner = Cleaner(tags=tags, attributes=attrs) # doesn't do anything, but i used to be able to pass it via constructor. @@ -172,6 +181,23 @@ def protect_links(soup, cleaner): for a_tag in soup.find_all("a"): uri = miniuri.Uri(a_tag.attrs.get("href", "")) + # Add shop ribbon color styling to all links + if hasattr(cleaner, 'shop') and cleaner.shop: + link_color = cleaner.shop.theme_link_color + if link_color: + # Validate that the color looks like a valid CSS color + # Allow hex colors (#fff, #ffffff), rgb(), rgba(), hsl(), hsla(), and named colors + import re + color_pattern = r'^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|hsla\([^)]+\)|[a-zA-Z]+)$' + if re.match(color_pattern, link_color.strip()): + # Get existing style or create new one + existing_style = a_tag.attrs.get("style", "") + if existing_style and not existing_style.endswith(";"): + existing_style += ";" + # Add color styling with validation + new_style = f"{existing_style}color:{link_color.strip()};" + a_tag.attrs["style"] = new_style + if uri.hostname in cleaner.whitelist_domains: # domain in whitelist or relative URI so remove rel="nofollow". a_tag.attrs.pop("rel", None)