Fix DOGE payment underpayment detection and refund processing

- Fix early detection logic to use new_sum instead of received_amount for underpayment check
- Fix hardcoded XMR atomic units (1e12) used for DOGE refunds, use correct coin-specific units
- Add comprehensive tests for atomic unit conversions for DOGE (1e8) and XMR (1e12)
- Prevent false underpayment detection when dust or corrupted amounts exist in database

This fixes production issue where valid DOGE payments were incorrectly marked as underpaid
due to comparing old stored amount instead of new incoming transaction amount.
This commit is contained in:
Russell Ballestrini 2025-09-29 06:15:56 -04:00
parent 62e098a131
commit 0faa20ba4f
4 changed files with 311 additions and 10 deletions

View file

@ -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(

View file

@ -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 %}
<br/>
{% if pending_crypto_quotes %}
<button type="button" class="mps-button" style="background:#aaa; cursor:not-allowed;" disabled title="Complete or cancel your pending crypto payments first">Monero (XMR) - Complete Pending Payments First</button>
{% else %}
<form method="POST" action="{{ request.route_url('crypto_xmr_start') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.id }}" />
<button type="submit" class="mps-button" style="background:#4c4;">Yes, Confirm Checkout with Monero (XMR)</button>
</form>
{% endif %}
{% if not request.has_xmr_refund_address %}
<a href="/u/settings/crypto" style="color:#f59e0b; text-decoration:none; display:block; margin-top:10px; font-size:14px;">
⚠️ 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 %}
<br/>
{% if pending_crypto_quotes %}
<button type="button" class="mps-button" style="background:#aaa; cursor:not-allowed;" disabled title="Complete or cancel your pending crypto payments first">Dogecoin (DOGE) 🐕 - Complete Pending Payments First</button>
{% else %}
<form method="POST" action="{{ request.route_url('crypto_doge_start') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.id }}" />
<button type="submit" class="mps-button" style="background:#d4a574;">Yes, Confirm Checkout with Dogecoin (DOGE) 🐕</button>
</form>
{% endif %}
{% if not request.has_doge_refund_address %}
<a href="/u/settings/crypto" style="color:#f59e0b; text-decoration:none; display:block; margin-top:10px; font-size:14px;">
⚠️ Configure a DOGE refund address to enable automatic refunds during payment errors.

View file

@ -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."""

View file

@ -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(