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 %}
+ {% 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 %}
+ {% 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(