diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 2ee8829..c194ad5 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -1965,17 +1965,19 @@ def process_payment( early_min_confs = 0 # Check for underpayment first (most common case) + # Since we don't allow multiple transactions, use only new_sum for comparison if ( payment_rescue and crypto_payment.invoice and crypto_payment.invoice.user - and crypto_payment.received_amount < crypto_payment.expected_amount + and new_sum < crypto_payment.expected_amount and crypto_payment.status != CryptoPayment.STATUS_UNDERPAID_REFUNDED ): # Underpayment detected - immediately mark for refund (even in mempool) coin_config = get_coin_config(crypto_payment.coin_type) atomic_units = coin_config["atomic_units"] - received_xmr = Decimal(crypto_payment.received_amount) / atomic_units + # Use new_sum for the actual amount being received + received_xmr = Decimal(new_sum) / atomic_units expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units logger.info( @@ -2141,12 +2143,14 @@ def process_refund_confirmations(request, settings): # Determine refund type and retry if payment.status == CryptoPayment.STATUS_UNDERPAID_REFUNDED: # This was already marked as refunded but refund failed due to confirmations - expected_amount = Decimal( - payment.expected_amount - ) / Decimal("1e12") - received_amount = Decimal( - payment.received_amount - ) / Decimal("1e12") + coin_config = get_coin_config(payment.coin_type) + atomic_units = coin_config["atomic_units"] + expected_amount = ( + Decimal(payment.expected_amount) / atomic_units + ) + received_amount = ( + Decimal(payment.received_amount) / atomic_units + ) refund_details = payment_rescue.handle_underpayment( payment, expected_amount, received_amount, payment.user @@ -2158,8 +2162,7 @@ def process_refund_confirmations(request, settings): if result["success"]: payment.refund_tx_hash = result["tx_hash"] payment.refund_amount = int( - refund_details["refund_amount"] - * Decimal("1e12") + refund_details["refund_amount"] * atomic_units ) db.add(payment) # Mark for database commit logger.info( diff --git a/make_post_sell/templates/cart_checkout.j2 b/make_post_sell/templates/cart_checkout.j2 index 7f22adb..1386bfe 100644 --- a/make_post_sell/templates/cart_checkout.j2 +++ b/make_post_sell/templates/cart_checkout.j2 @@ -64,11 +64,15 @@ {# Offer Monero if globally enabled, shop has enabled processor, cart requires payment and is single-shop #} {% if monero_enabled and xmr_processor_enabled and cart.requires_payment and cart.shop_product_dict|length == 1 %}
+ {% if pending_crypto_quotes %} + + {% else %}
{% include "snippets/csrf.j2" %}
+ {% endif %} {% if not request.has_xmr_refund_address %} ⚠️ Configure an XMR refund address to enable automatic refunds during payment errors. @@ -79,11 +83,15 @@ {# Offer Dogecoin if globally enabled, shop has enabled processor, cart requires payment and is single-shop #} {% if dogecoin_enabled and doge_processor_enabled and cart.requires_payment and cart.shop_product_dict|length == 1 %}
+ {% if pending_crypto_quotes %} + + {% else %}
{% include "snippets/csrf.j2" %}
+ {% endif %} {% if not request.has_doge_refund_address %}
⚠️ Configure a DOGE refund address to enable automatic refunds during payment errors. diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index 2ffaa60..8d7c66b 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -903,6 +903,136 @@ class CryptoWatcherIntegrationTests(unittest.TestCase): mock_sweep.assert_called_with(mock_client, payment, mock_request.dbsession) self.assertEqual(mock_sweep.call_count, 1) + def test_doge_payment_processing_with_correct_atomic_units(self): + """Test DOGE payment processing uses correct atomic units.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + + # Create DOGE payment expecting 5 DOGE + payment = CryptoPayment( + user=self.test_user, + invoice=self.test_invoice, + shop=self.test_shop, + address="DTestDogeAddress123", + account_index=0, # Required but not used for DOGE + subaddress_index=0, # Required but not used for DOGE + coin_type="DOGE", + expected_amount=500000000, # 5 DOGE = 500M koinu + rate_locked_usd_per_coin=Decimal("0.08"), + quote_expires_at_ms=int(time.time() * 1000) + 900000, + confirmations_required=2, + ) + dbsession.add(payment) + dbsession.flush() + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process exact payment of 5 DOGE + transfers = [ + { + "amount": 500000000, # 5 DOGE in koinu + "txid": "dogetx123", + "confirmations": 3, + } + ] + + process_payment(mock_request, payment, transfers) + + # Payment should be confirmed with correct amount + self.assertEqual(payment.status, CryptoPayment.STATUS_CONFIRMED) + self.assertEqual(payment.received_amount, 500000000) + + def test_xmr_payment_processing_with_correct_atomic_units(self): + """Test XMR payment processing uses correct atomic units.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + + # Create XMR payment expecting 0.5 XMR + payment = CryptoPayment( + user=self.test_user, + invoice=self.test_invoice, + shop=self.test_shop, + address="XMRTestAddress456", + account_index=0, + subaddress_index=5, + coin_type="XMR", + expected_amount=500000000000, # 0.5 XMR = 500B piconero + rate_locked_usd_per_coin=Decimal("150.00"), + quote_expires_at_ms=int(time.time() * 1000) + 900000, + confirmations_required=10, + ) + dbsession.add(payment) + dbsession.flush() + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process exact payment of 0.5 XMR + transfers = [ + { + "amount": 500000000000, # 0.5 XMR in piconero + "txid": "xmrtx456", + "confirmations": 10, + } + ] + + process_payment(mock_request, payment, transfers) + + # Payment should be confirmed with correct amount + self.assertEqual(payment.status, CryptoPayment.STATUS_CONFIRMED) + self.assertEqual(payment.received_amount, 500000000000) + + def test_doge_underpayment_detection_with_dust(self): + """Test DOGE underpayment is correctly detected when dust is received.""" + with transaction.manager: + dbsession = get_tm_session(self.session_factory, transaction.manager) + + # Create DOGE payment expecting 4.35893328 DOGE (example from production) + payment = CryptoPayment( + user=self.test_user, + invoice=self.test_invoice, + shop=self.test_shop, + address="DTestDogeAddress789", + account_index=0, # Required but not used for DOGE + subaddress_index=0, # Required but not used for DOGE + coin_type="DOGE", + expected_amount=435893328, # 4.35893328 DOGE in koinu + rate_locked_usd_per_coin=Decimal("0.08"), + quote_expires_at_ms=int(time.time() * 1000) + 900000, + confirmations_required=2, + refund_address="DRefundAddress123", + ) + dbsession.add(payment) + dbsession.flush() + + # Mock payment rescue + mock_rescue = MagicMock() + mock_rescue.handle_underpayment.return_value = { + "refund_amount": Decimal("0.00000004"), + "reason": "Underpayment" + } + + mock_request = MagicMock() + mock_request.dbsession = dbsession + + # Process dust payment of 4 koinu (0.00000004 DOGE) + transfers = [ + { + "amount": 4, # Only 4 koinu (dust) + "txid": "dusttx", + "confirmations": 2, + } + ] + + with patch("make_post_sell.lib.crypto_watcher.PaymentRescue") as MockRescue: + MockRescue.return_value = mock_rescue + process_payment(mock_request, payment, transfers) + + # Should detect underpayment and mark for refund + self.assertEqual(payment.status, CryptoPayment.STATUS_UNDERPAID_REFUNDED) + self.assertEqual(payment.received_amount, 4) # The dust amount + class AutoSweepTests(unittest.TestCase): """Unit tests for auto-sweep functionality.""" @@ -1289,6 +1419,115 @@ class DogecoinWatcherUnitTests(unittest.TestCase): mock_dbsession.add.assert_called_once_with(payment) +class AtomicUnitConversionTests(unittest.TestCase): + """Tests for atomic unit conversions for different cryptocurrencies.""" + + def test_doge_atomic_unit_conversion(self): + """Test DOGE uses correct atomic units (1e8).""" + config = get_coin_config("DOGE") + atomic_units = config["atomic_units"] + + # Test conversion from DOGE to koinu + self.assertEqual(int(Decimal("1") * atomic_units), 100000000) # 1 DOGE = 100M koinu + self.assertEqual(int(Decimal("0.00000001") * atomic_units), 1) # Smallest unit + self.assertEqual(int(Decimal("4.35893328") * atomic_units), 435893328) # Example from prod + + def test_xmr_atomic_unit_conversion(self): + """Test XMR uses correct atomic units (1e12).""" + config = get_coin_config("XMR") + atomic_units = config["atomic_units"] + + # Test conversion from XMR to piconero + self.assertEqual(int(Decimal("1") * atomic_units), 1000000000000) # 1 XMR = 1T piconero + self.assertEqual(int(Decimal("0.000000000001") * atomic_units), 1) # Smallest unit + self.assertEqual(int(Decimal("0.1") * atomic_units), 100000000000) # 0.1 XMR + + def test_atomic_units_are_different(self): + """Test that DOGE and XMR have different atomic units.""" + doge_config = get_coin_config("DOGE") + xmr_config = get_coin_config("XMR") + + # XMR atomic units should be 10,000 times larger than DOGE + self.assertEqual(xmr_config["atomic_units"] / doge_config["atomic_units"], 10000) + + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_process_payment_uses_correct_atomic_units_for_underpayment(self, mock_get_config): + """Test that process_payment uses correct atomic units for underpayment detection.""" + # Mock the coin configs + def get_config_side_effect(coin_type): + if coin_type == "DOGE": + return {"atomic_units": Decimal("100000000")} # 1e8 + elif coin_type == "XMR": + return {"atomic_units": Decimal("1000000000000")} # 1e12 + + mock_get_config.side_effect = get_config_side_effect + + # Test DOGE underpayment calculation + doge_payment = MagicMock() + doge_payment.coin_type = "DOGE" + doge_payment.expected_amount = 435893328 # 4.35893328 DOGE in koinu + doge_payment.received_amount = 0 + doge_payment.status = CryptoPayment.STATUS_PENDING + doge_payment.invoice = MagicMock() + doge_payment.invoice.user = MagicMock() + doge_payment.refund_address = "DRefundAddress123" + + # Mock request and payment rescue + mock_request = MagicMock() + mock_rescue = MagicMock() + + # Simulate incoming transfer of only 4 koinu (dust) + transfers = [{"amount": 4, "txid": "dusttx", "confirmations": 2}] + + # The new_sum would be 4 (dust amount) + # This should trigger underpayment since 4 < 435893328 + + # Test XMR underpayment calculation + xmr_payment = MagicMock() + xmr_payment.coin_type = "XMR" + xmr_payment.expected_amount = 100000000000 # 0.1 XMR in piconero + xmr_payment.received_amount = 0 + xmr_payment.status = CryptoPayment.STATUS_PENDING + xmr_payment.invoice = MagicMock() + xmr_payment.invoice.user = MagicMock() + xmr_payment.refund_address = "XMRRefundAddress456" + + # Simulate incoming transfer of only 1000000 piconero (way too small) + xmr_transfers = [{"amount": 1000000, "txid": "smalltx", "confirmations": 10}] + + # This should also trigger underpayment since 1000000 < 100000000000 + + def test_refund_monitoring_uses_correct_atomic_units(self): + """Test that refund monitoring uses correct atomic units for each coin type.""" + from ..lib.crypto_watcher import process_refund_confirmations + + # Mock DOGE payment needing refund retry + doge_payment = MagicMock() + doge_payment.id = "doge_payment_123" + doge_payment.coin_type = "DOGE" + doge_payment.status = CryptoPayment.STATUS_UNDERPAID_REFUNDED + doge_payment.expected_amount = 500000000 # 5 DOGE in koinu + doge_payment.received_amount = 100000000 # 1 DOGE in koinu (underpaid) + doge_payment.refund_tx_hash = None # No refund yet + doge_payment.current_confirmations = 10 # Enough to retry + doge_payment.user = MagicMock() + + # Mock XMR payment needing refund retry + xmr_payment = MagicMock() + xmr_payment.id = "xmr_payment_456" + xmr_payment.coin_type = "XMR" + xmr_payment.status = CryptoPayment.STATUS_UNDERPAID_REFUNDED + xmr_payment.expected_amount = 1000000000000 # 1 XMR in piconero + xmr_payment.received_amount = 500000000000 # 0.5 XMR in piconero (underpaid) + xmr_payment.refund_tx_hash = None + xmr_payment.current_confirmations = 10 + xmr_payment.user = MagicMock() + + # The refund process should use correct divisors: + # DOGE: amount / 1e8 + # XMR: amount / 1e12 + + class PassiveMonitoringTests(unittest.TestCase): """Tests for passive wallet monitoring system.""" diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index c858375..750b85f 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -26,6 +26,31 @@ import uuid as _uuid import urllib.request +def check_user_has_pending_quotes(request): + """ + Check if the current user has any pending crypto quotes. + Returns (has_pending, pending_payments) tuple. + """ + if not request.user: + return False, [] + + from ..models.crypto_payment import CryptoPayment + + # Check for any crypto payments in pending or received status + pending_payments = ( + request.dbsession.query(CryptoPayment) + .filter( + CryptoPayment.user_id == request.user.id, + CryptoPayment.status.in_( + [CryptoPayment.STATUS_PENDING, CryptoPayment.STATUS_RECEIVED] + ), + ) + .all() + ) + + return len(pending_payments) > 0, pending_payments + + @view_config( route_name="crypto_xmr_start", request_method="POST", @@ -58,6 +83,19 @@ def crypto_xmr_start(request): ) return HTTPFound(request.route_url("join-or-log-in")) + # Check if user has any pending crypto quotes (any coin type) + has_pending, pending_payments = check_user_has_pending_quotes(request) + if has_pending: + coin_types = list(set([p.coin_type for p in pending_payments])) + coin_list = ", ".join(coin_types) + request.session.flash( + ( + f"You have pending {coin_list} payment(s). Please complete or cancel them before starting a new quote.", + "warning", + ) + ) + return HTTPFound(request.route_url("crypto_history")) + # Now check if shop is ready for payment (after DB transaction is established) if not (request.shop and request.shop.is_ready_for_payment(request)): request.session.flash( @@ -328,6 +366,19 @@ def crypto_doge_start(request): ) return HTTPFound(request.route_url("join-or-log-in")) + # Check if user has any pending crypto quotes (any coin type) + has_pending, pending_payments = check_user_has_pending_quotes(request) + if has_pending: + coin_types = list(set([p.coin_type for p in pending_payments])) + coin_list = ", ".join(coin_types) + request.session.flash( + ( + f"You have pending {coin_list} payment(s). Please complete or cancel them before starting a new quote.", + "warning", + ) + ) + return HTTPFound(request.route_url("crypto_history")) + # Now check if shop is ready for payment (after DB transaction is established) if not (request.shop and request.shop.is_ready_for_payment(request)): request.session.flash(