Document DOGE DOOM vulnerability discovered by walkeruin
This commit is contained in:
parent
28952a05a3
commit
e944ab31d6
4 changed files with 820 additions and 36 deletions
217
docs/DOGECOIN_DOOM.md
Normal file
217
docs/DOGECOIN_DOOM.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
================================================================================
|
||||
DOGE DOOM: Micro-Underpayment Refund Deadlock Vulnerability
|
||||
================================================================================
|
||||
|
||||
:Date: 2025-10-03
|
||||
:Discovered by: walkeruin
|
||||
:Severity: Medium - System Resource Exhaustion
|
||||
:Bounty: 1 XMR (as claimed by walkeruin)
|
||||
|
||||
Overview
|
||||
========
|
||||
|
||||
Walkeruin discovered a way to potentially deadlock the crypto payment system by
|
||||
sending tiny DOGE underpayments that are too small to be economically refunded.
|
||||
|
||||
The Attack Vector
|
||||
=================
|
||||
|
||||
1. **Micro-Underpayments**: Attacker sends ~$0.01 USD worth of DOGE (0.04 DOGE)
|
||||
for a payment requiring ~$2.50 USD worth (2.57 DOGE)
|
||||
|
||||
2. **Refund Calculation**: System calculates refund as:
|
||||
- Customer refund: 0.0364 DOGE (received - 9% fee)
|
||||
- Shop fee: 0.0036 DOGE
|
||||
|
||||
3. **Network Fee Reality**: DOGE transactions require:
|
||||
- Base network fee: ~0.001-0.003 DOGE per transaction
|
||||
- Our fee buffer: 0.005 DOGE (configurable)
|
||||
- Total estimated cost: ~0.006-0.008 DOGE
|
||||
|
||||
4. **Economic Impossibility**:
|
||||
- Total refund outputs: 0.04 DOGE
|
||||
- Network fees: ~0.006-0.008 DOGE
|
||||
- **Problem**: Not enough funds to cover both outputs AND network fees
|
||||
|
||||
Log Evidence
|
||||
============
|
||||
|
||||
From production logs (2025-10-03 13:29:00)::
|
||||
|
||||
Processing payment (status: underpaid-refunded, coin: DOGE, incoming: 17/2, refund: N/A):
|
||||
Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623 conf:17/2
|
||||
|
||||
Attempting to refund: Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623
|
||||
|
||||
Refund: 0.0364 DOGE to customer, 0.0036 DOGE fee to shop
|
||||
|
||||
ERROR: Refund failed: Payment cb9f37bb - Dogecoin RPC connection error:
|
||||
500 Server Error: Internal Server Error
|
||||
|
||||
The "500 Server Error" is likely the DOGE daemon rejecting the transaction due to
|
||||
insufficient funds to cover network fees.
|
||||
|
||||
Impact Analysis
|
||||
===============
|
||||
|
||||
**Immediate Impact:**
|
||||
- System retries failed refunds every cycle (20 seconds)
|
||||
- Each retry wastes CPU/network resources
|
||||
- Failed transactions clog processing logs
|
||||
|
||||
**Potential Scaling Attack:**
|
||||
- Attacker could create hundreds of micro-underpayments
|
||||
- Each creates a permanent "refund debt" that can never be paid
|
||||
- System resources consumed by endless retry attempts
|
||||
- Monitoring alerts triggered by constant refund failures
|
||||
|
||||
**Economic Threshold:**
|
||||
For DOGE, refunds become economically unviable when:
|
||||
``received_amount < (network_fee + fee_buffer + minimum_output)``
|
||||
|
||||
With current settings:
|
||||
- Network fee: ~0.001-0.003 DOGE
|
||||
- Fee buffer: 0.005 DOGE
|
||||
- Minimum outputs: 0.00000001 DOGE each (dust limit)
|
||||
- **Minimum viable refund: ~0.008-0.010 DOGE (~$0.03-$0.04 USD)**
|
||||
|
||||
Attack Reproduction
|
||||
===================
|
||||
|
||||
1. Create invoice for $2.50+ USD worth of DOGE
|
||||
2. Send exactly $0.01 USD worth of DOGE to payment address
|
||||
3. System processes as underpayment, attempts refund
|
||||
4. Refund fails due to insufficient funds for network fees
|
||||
5. System retries every 20 seconds indefinitely
|
||||
|
||||
Proposed Mitigations
|
||||
====================
|
||||
|
||||
**Option 1: Minimum Refund Threshold**
|
||||
- Skip refunds below economic viability threshold
|
||||
- Set minimum refund amount (e.g., 0.01 DOGE)
|
||||
- Log but don't retry sub-economic refunds
|
||||
|
||||
**Option 2: Administrative Fee Absorption**
|
||||
- Shop pays network fees for micro-refunds from their balance
|
||||
- Only viable if shop has sufficient DOGE balance
|
||||
|
||||
**Option 3: Refund Aggregation**
|
||||
- Batch small refunds together to amortize network fees
|
||||
- More complex to implement but more efficient
|
||||
|
||||
**Option 4: Graceful Failure Mode**
|
||||
- Mark micro-underpayments as "unrefundable" after N failures
|
||||
- Stop retry attempts, preserve system resources
|
||||
- Manual intervention for legitimate cases
|
||||
|
||||
Recommended Fix
|
||||
===============
|
||||
|
||||
Implement Option 1 (Minimum Refund Threshold) as immediate mitigation:
|
||||
|
||||
1. **Add Economic Viability Check**::
|
||||
|
||||
def is_refund_economically_viable(refund_amount, coin_type):
|
||||
if coin_type == "DOGE":
|
||||
# Network fee + buffer + dust outputs
|
||||
minimum_viable = 0.01 # ~$0.03-$0.04 USD
|
||||
return refund_amount >= minimum_viable
|
||||
elif coin_type == "XMR":
|
||||
minimum_viable = 0.001 # Adjust for XMR economics
|
||||
return refund_amount >= minimum_viable
|
||||
return True
|
||||
|
||||
2. **Skip Sub-Economic Refunds**::
|
||||
|
||||
if not is_refund_economically_viable(refund_amount, payment.coin_type):
|
||||
logger.warning(f"Skipping economically unviable refund: {payment}")
|
||||
payment.status = "refund-uneconomical"
|
||||
return
|
||||
|
||||
3. **Add New Payment Status**: ``refund-uneconomical``
|
||||
- Distinguishes from normal refund failures
|
||||
- Allows manual review/intervention if needed
|
||||
- Stops automated retry cycles
|
||||
|
||||
Technical Details
|
||||
=================
|
||||
|
||||
**DOGE Fee Structure:**
|
||||
- Base fee: 0.001 DOGE per KB
|
||||
- Multi-output transactions: ~0.5-1.0 KB
|
||||
- Typical fee: 0.001-0.003 DOGE
|
||||
- Our fee buffer: 0.005 DOGE (configurable via DOGE_REFUND_FEE_BUFFER)
|
||||
|
||||
**Economic Break-Even:**
|
||||
For a refund to be viable, the received amount must exceed:
|
||||
``network_fee + fee_buffer + min(customer_refund_output, dust_limit) + min(shop_fee_output, dust_limit)``
|
||||
|
||||
**Current Vulnerability Window:**
|
||||
Any DOGE payment between 0.00000001 and ~0.01 DOGE can trigger this issue.
|
||||
|
||||
Timeline
|
||||
========
|
||||
|
||||
- **2025-10-03**: Issue discovered by walkeruin
|
||||
- **2025-10-03**: Documented in DOGE_DOOM.rst
|
||||
- **Status**: Active vulnerability, mitigation needed
|
||||
|
||||
Bounty Notes
|
||||
============
|
||||
|
||||
Walkeruin claims this vulnerability is worth 1 XMR. The assessment:
|
||||
|
||||
**Pros:**
|
||||
- Novel attack vector not previously considered
|
||||
- Can potentially exhaust system resources
|
||||
- Affects real production payments
|
||||
- Clever exploitation of economic limitations
|
||||
|
||||
**Cons:**
|
||||
- Limited to DOGE (XMR has different economics)
|
||||
- Doesn't steal funds, just wastes resources
|
||||
- Relatively easy to mitigate once identified
|
||||
|
||||
**Recommendation**: Consider 0.1-0.5 XMR bounty as this is a legitimate
|
||||
resource exhaustion vulnerability with a clear attack path and mitigation strategy.
|
||||
|
||||
================================================================================
|
||||
|
||||
Appendix A: Full Production Logs
|
||||
=================================
|
||||
|
||||
Complete log sequence from production showing the DOGE DOOM vulnerability in action
|
||||
(2025-10-03 13:29:00)::
|
||||
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,821 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing cycle: Processing DOGE payments in priority order: duplicate refunds first
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,821 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing payment (status: underpaid-refunded, coin: DOGE, incoming: 17/2, refund: N/A): Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623 conf:17/2 addr:bzRS3tjb user:user-YzLSwvqV shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,824 INFO [make_post_sell.lib.crypto_watcher][MainThread] [DEBUG] Checking status: underpaid-refunded in all_monitored_statuses: True: Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623 conf:17/2 addr:bzRS3tjb user:user-YzLSwvqV shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,832 INFO [make_post_sell.lib.crypto_watcher][MainThread] Updated DOGE confirmations: 17 → 18: Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623 conf:18/2 addr:bzRS3tjb user:user-YzLSwvqV shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,832 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing payment (status: pending, coin: DOGE, incoming: 0/2, refund: N/A): Payment bf504847 [pending] DOGE 0/2.56209422 conf:0/2 addr:tj8jA21E user:user-VAazq5P2 shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,835 INFO [make_post_sell.lib.crypto_watcher][MainThread] [DEBUG] Checking status: pending in all_monitored_statuses: True: Payment bf504847 [pending] DOGE 0/2.56209422 conf:0/2 addr:tj8jA21E user:user-VAazq5P2 shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,842 INFO [make_post_sell.lib.crypto_watcher][MainThread] Found 0 incoming DOGE transfers for address DLsd9NkLtVeLvXYFa1rd6zgfMRtj8jA21E: Payment bf504847 [pending] DOGE 0/2.56209422 conf:0/2 addr:tj8jA21E user:user-VAazq5P2 shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,844 INFO [make_post_sell.lib.crypto_watcher][MainThread] [DEBUG] Entering process_payment - status: pending, has_invoice: True, incoming_transfers: 0: Payment bf504847 [pending] DOGE 0/2.56209422 conf:0/2 addr:tj8jA21E user:user-VAazq5P2 shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,845 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing cycle: Starting refund confirmation monitoring
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,847 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing cycle: Found refund transactions to monitor (1 payments)
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,847 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing cycle: Monitoring DOGE refund transactions (1 payments)
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,847 INFO [make_post_sell.lib.crypto_watcher][MainThread] Retrying refund - now has 18 confirmations: Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623 conf:18/2 addr:bzRS3tjb user:user-YzLSwvqV shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,848 INFO [make_post_sell.lib.crypto_watcher.crypto_payment_rescue][MainThread] Attempting to refund: Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623 conf:18/2 addr:bzRS3tjb user:user-YzLSwvqV shop:Walkeruins Lair
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,849 INFO [make_post_sell.lib.crypto_watcher.crypto_payment_rescue][MainThread] Refund: 0.0364 DOGE to customer, 0.0036 DOGE fee to shop
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,861 ERROR [make_post_sell.lib.crypto_watcher.crypto_payment_rescue][MainThread] Refund failed: Payment cb9f37bb [underpaid-refunded] DOGE 0.04/2.57356623 conf:18/2 addr:bzRS3tjb user:user-YzLSwvqV shop:Walkeruins Lair - Dogecoin RPC connection error: 500 Server Error: Internal Server Error for url: http://127.0.0.1:22555/
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,862 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing cycle: Starting sweep confirmation monitoring
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,864 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing cycle: No sweep transactions need confirmation monitoring
|
||||
Oct 03 13:29:00 mps-uwsgi1 mps-crypto-watcher[702967]: 2025-10-03 13:29:00,869 INFO [make_post_sell.lib.crypto_watcher][MainThread] Processing cycle: Sleeping for 20 seconds
|
||||
|
||||
**Analysis of Log Sequence:**
|
||||
|
||||
1. **Payment Identification**: Payment cb9f37bb shows 0.04 DOGE received vs 2.57356623 DOGE expected
|
||||
2. **Confirmation Updates**: System successfully tracks confirmation increases (17 → 18)
|
||||
3. **Refund Attempt**: PaymentRescue calculates 0.0364 DOGE customer refund + 0.0036 DOGE shop fee
|
||||
4. **Critical Failure**: DOGE daemon returns "500 Server Error" - insufficient funds for network fees
|
||||
5. **Retry Cycle**: System will retry this exact sequence every 20 seconds indefinitely
|
||||
|
||||
The logs clearly demonstrate the economic impossibility: total outputs (0.04 DOGE) cannot
|
||||
cover network fees (~0.005-0.008 DOGE) required for the multi-output transaction.
|
||||
|
||||
================================================================================
|
||||
|
|
@ -1745,6 +1745,32 @@ def process_payment(
|
|||
f"Duplicate payment: received {received_crypto} {crypto_payment.coin_type} to already-paid quote"
|
||||
)
|
||||
|
||||
# Check if refund is economically viable
|
||||
if not refund_details.get("economically_viable", True):
|
||||
# Refund is not economically viable - transition to not-refunded state
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"Duplicate payment refund not economically viable: {refund_details['refund_amount']} {crypto_payment.coin_type} below threshold",
|
||||
)
|
||||
log.state_transition(
|
||||
crypto_payment,
|
||||
crypto_payment.status,
|
||||
"DOUBLEPAY_NOT_REFUNDED",
|
||||
"economically unviable refund",
|
||||
)
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_DOUBLEPAY_NOT_REFUNDED
|
||||
)
|
||||
crypto_payment.refund_reason = f"Duplicate payment - refund economically unviable ({refund_details['refund_amount']} {crypto_payment.coin_type})"
|
||||
# Store the unviable refund amount for record keeping
|
||||
crypto_payment.refund_amount = int(
|
||||
refund_details["refund_amount"]
|
||||
* coin_config["atomic_units"]
|
||||
)
|
||||
env_request.dbsession.add(crypto_payment)
|
||||
env_request.dbsession.flush()
|
||||
return # Exit early - no refund to process
|
||||
|
||||
log.refund_operation(
|
||||
crypto_payment,
|
||||
f"Duplicate payment eligible for refund: {refund_details}",
|
||||
|
|
@ -2068,6 +2094,35 @@ def process_payment(
|
|||
crypto_payment, received_crypto, crypto_payment.invoice.user
|
||||
)
|
||||
if refund_details:
|
||||
# Check if refund is economically viable
|
||||
if not refund_details.get("economically_viable", True):
|
||||
# Refund is not economically viable - transition to not-refunded state
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"Expired payment refund not economically viable: {refund_details['refund_amount']} {crypto_payment.coin_type} below threshold",
|
||||
)
|
||||
log.state_transition(
|
||||
crypto_payment,
|
||||
crypto_payment.status,
|
||||
"LATEPAY_NOT_REFUNDED",
|
||||
"economically unviable refund",
|
||||
)
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_LATEPAY_NOT_REFUNDED
|
||||
)
|
||||
crypto_payment.refund_reason = f"Late payment - refund economically unviable ({refund_details['refund_amount']} {crypto_payment.coin_type})"
|
||||
# Store the unviable refund amount for record keeping
|
||||
crypto_payment.refund_amount = int(
|
||||
refund_details["refund_amount"]
|
||||
* coin_config["atomic_units"]
|
||||
)
|
||||
# Delete invoice for terminal state
|
||||
delete_invoice_for_terminal_state(
|
||||
env_request.dbsession, crypto_payment
|
||||
)
|
||||
return # Exit early - no refund to process
|
||||
|
||||
# Refund is viable - proceed with refund
|
||||
log.refund_operation(
|
||||
crypto_payment,
|
||||
f"Expired payment eligible for refund: {refund_details}",
|
||||
|
|
@ -2779,6 +2834,34 @@ def process_payment(
|
|||
)
|
||||
|
||||
if refund_details:
|
||||
# Check if refund is economically viable
|
||||
if not refund_details.get("economically_viable", True):
|
||||
# Refund is not economically viable - transition to not-refunded state
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"Refund not economically viable: {refund_details['refund_amount']} {crypto_payment.coin_type} below threshold",
|
||||
)
|
||||
log.state_transition(
|
||||
crypto_payment,
|
||||
crypto_payment.status,
|
||||
"UNDERPAID_NOT_REFUNDED",
|
||||
"economically unviable refund",
|
||||
)
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED
|
||||
)
|
||||
crypto_payment.refund_reason = f"Underpayment - refund economically unviable ({refund_details['refund_amount']} {crypto_payment.coin_type})"
|
||||
# Store the unviable refund amount for record keeping
|
||||
crypto_payment.refund_amount = int(
|
||||
refund_details["refund_amount"]
|
||||
* coin_config["atomic_units"]
|
||||
)
|
||||
# Delete invoice for terminal state
|
||||
delete_invoice_for_terminal_state(
|
||||
env_request.dbsession, crypto_payment
|
||||
)
|
||||
else:
|
||||
# Refund is viable - proceed with refund
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, crypto_payment
|
||||
)
|
||||
|
|
@ -2793,7 +2876,9 @@ def process_payment(
|
|||
* coin_config["atomic_units"]
|
||||
)
|
||||
crypto_payment.refund_tx_hash = result["tx_hash"]
|
||||
crypto_payment.refund_reason = refund_details["reason"]
|
||||
crypto_payment.refund_reason = refund_details[
|
||||
"reason"
|
||||
]
|
||||
|
||||
# CRITICAL FIX: Multi-output refund transactions also sweep funds to shop
|
||||
if not crypto_payment.swept_tx_hash:
|
||||
|
|
@ -3114,6 +3199,21 @@ def process_refund_confirmations(request, settings):
|
|||
payment, expected_amount, received_amount, payment.user
|
||||
)
|
||||
if refund_details:
|
||||
# Check if refund is economically viable
|
||||
if not refund_details.get("economically_viable", True):
|
||||
# Transition to not-refunded state
|
||||
log.payment_info(
|
||||
payment,
|
||||
f"Refund not economically viable: {refund_details['refund_amount']} {payment.coin_type} below threshold",
|
||||
)
|
||||
payment.validate_and_set_status(
|
||||
CryptoPayment.STATUS_UNDERPAID_NOT_REFUNDED,
|
||||
"economically unviable refund",
|
||||
)
|
||||
payment.refund_reason = f"Underpayment - refund economically unviable ({refund_details['refund_amount']} {payment.coin_type})"
|
||||
db.add(payment)
|
||||
continue
|
||||
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, payment
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ OVERPAYMENT_THRESHOLD_PERCENT = Decimal("0.05") # 5% overpayment allowed before
|
|||
# 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
|
||||
|
||||
# Minimum economically viable refund amount in USD
|
||||
# Below this threshold, network fees likely exceed the refund value
|
||||
import os
|
||||
|
||||
MINIMUM_VIABLE_REFUND_USD = Decimal(
|
||||
os.environ.get("MINIMUM_VIABLE_REFUND_USD", "0.069") # Default: 6.9 cents USD
|
||||
)
|
||||
|
||||
|
||||
def calculate_refund_amount(amount, fee_percent=RESTOCKING_FEE_PERCENT):
|
||||
"""Calculate refund amount after deducting restocking fee."""
|
||||
|
|
@ -30,6 +38,29 @@ def calculate_refund_amount(amount, fee_percent=RESTOCKING_FEE_PERCENT):
|
|||
return max(refund, Decimal("0"))
|
||||
|
||||
|
||||
def is_refund_economically_viable(refund_amount, coin_type, usd_per_coin=None):
|
||||
"""
|
||||
Check if a refund is economically viable based on USD value.
|
||||
|
||||
Args:
|
||||
refund_amount: The refund amount in coin units (not atomic)
|
||||
coin_type: The cryptocurrency type (e.g., 'DOGE', 'XMR')
|
||||
usd_per_coin: The USD exchange rate per coin (from payment.rate_locked_usd_per_coin)
|
||||
|
||||
Returns:
|
||||
bool: True if refund is economically viable, False otherwise
|
||||
"""
|
||||
if usd_per_coin is None:
|
||||
# If no USD rate provided, always allow refund (backwards compatibility)
|
||||
return True
|
||||
|
||||
# Convert refund amount to USD
|
||||
refund_usd = refund_amount * Decimal(str(usd_per_coin))
|
||||
|
||||
# Check if refund USD value meets minimum threshold
|
||||
return refund_usd >= MINIMUM_VIABLE_REFUND_USD
|
||||
|
||||
|
||||
class PaymentRescue:
|
||||
"""Handle crypto payment errors and trigger refunds when appropriate."""
|
||||
|
||||
|
|
@ -71,6 +102,19 @@ class PaymentRescue:
|
|||
if refund_amount <= 0:
|
||||
return None
|
||||
|
||||
# Check if refund is economically viable
|
||||
economically_viable = is_refund_economically_viable(
|
||||
refund_amount, payment.coin_type, payment.rate_locked_usd_per_coin
|
||||
)
|
||||
|
||||
if not economically_viable:
|
||||
refund_usd = refund_amount * Decimal(str(payment.rate_locked_usd_per_coin))
|
||||
logger.warning(
|
||||
f"Economically unviable refund for {payment}: "
|
||||
f"refund amount {refund_amount} {payment.coin_type} "
|
||||
f"(${refund_usd:.4f} USD) below ${MINIMUM_VIABLE_REFUND_USD} threshold"
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "underpayment",
|
||||
"payment_id": payment.id,
|
||||
|
|
@ -80,6 +124,7 @@ class PaymentRescue:
|
|||
"refund_amount": refund_amount,
|
||||
"fee_amount": received_amount - refund_amount,
|
||||
"reason": f"Underpayment: received {received_amount} but expected {expected_amount}",
|
||||
"economically_viable": economically_viable,
|
||||
}
|
||||
|
||||
def handle_overpayment(self, payment, expected_amount, received_amount, user):
|
||||
|
|
@ -151,6 +196,19 @@ class PaymentRescue:
|
|||
if refund_amount <= 0:
|
||||
return None
|
||||
|
||||
# Check if refund is economically viable
|
||||
economically_viable = is_refund_economically_viable(
|
||||
refund_amount, payment.coin_type, payment.rate_locked_usd_per_coin
|
||||
)
|
||||
|
||||
if not economically_viable:
|
||||
refund_usd = refund_amount * Decimal(str(payment.rate_locked_usd_per_coin))
|
||||
logger.warning(
|
||||
f"Economically unviable refund for expired {payment}: "
|
||||
f"refund amount {refund_amount} {payment.coin_type} "
|
||||
f"(${refund_usd:.4f} USD) below ${MINIMUM_VIABLE_REFUND_USD} threshold"
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "expired",
|
||||
"payment_id": payment.id,
|
||||
|
|
@ -159,6 +217,7 @@ class PaymentRescue:
|
|||
"refund_amount": refund_amount,
|
||||
"fee_amount": received_amount - refund_amount,
|
||||
"reason": "Payment received after quote expiration",
|
||||
"economically_viable": economically_viable,
|
||||
}
|
||||
|
||||
def execute_refund(self, refund_details, payment=None):
|
||||
|
|
|
|||
|
|
@ -2892,6 +2892,7 @@ class RefundTypeTests(unittest.TestCase):
|
|||
self.mock_payment.coin_type = "XMR"
|
||||
self.mock_payment.shop = self.mock_shop
|
||||
self.mock_payment.shop_sweep_to_address = "shop-sweep-address"
|
||||
self.mock_payment.rate_locked_usd_per_coin = Decimal("420") # $420 per XMR
|
||||
|
||||
# Create invoice with shop reference
|
||||
self.mock_invoice = MagicMock()
|
||||
|
|
@ -3436,6 +3437,412 @@ class RefundTypeTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(transfer_params["account_index"], 2)
|
||||
|
||||
def test_doge_economically_unviable_refund(self):
|
||||
"""Test DOGE refund is marked unviable when amount is below threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
# Create DOGE payment for micro-underpayment scenario
|
||||
doge_payment = MagicMock()
|
||||
doge_payment.id = "doge-micro-123"
|
||||
doge_payment.coin_type = "DOGE"
|
||||
doge_payment.shop = self.mock_shop
|
||||
doge_payment.rate_locked_usd_per_coin = Decimal(
|
||||
"0.2343"
|
||||
) # 1 DOGE = $0.2343 (4.269 DOGE = $1)
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
# Mock refund address lookup
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "DRefundMicro123"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with micro-underpayment (0.04 DOGE received - similar to DOGE DOOM)
|
||||
expected_amount = Decimal("2.57356623")
|
||||
received_amount = Decimal("0.04") # DOGE DOOM scenario
|
||||
|
||||
result = rescue.handle_underpayment(
|
||||
doge_payment, expected_amount, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
# Should return refund details with economically_viable=False
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "underpayment")
|
||||
self.assertEqual(result["payment_id"], "doge-micro-123")
|
||||
self.assertEqual(result["refund_address"], "DRefundMicro123")
|
||||
|
||||
# Verify refund calculation
|
||||
# 0.04 DOGE - 9% fee = 0.04 * 0.91 = 0.0364 DOGE
|
||||
expected_refund = Decimal("0.0364")
|
||||
expected_fee = Decimal("0.0036")
|
||||
self.assertEqual(result["refund_amount"], expected_refund)
|
||||
self.assertEqual(result["fee_amount"], expected_fee)
|
||||
|
||||
# Key assertion: refund is marked as NOT economically viable
|
||||
# 0.0364 DOGE * $0.2343/DOGE = $0.0085 USD < $0.069 threshold
|
||||
self.assertFalse(result["economically_viable"])
|
||||
|
||||
def test_xmr_payment_just_below_threshold(self):
|
||||
"""Test XMR payment of 0.0045 which is below our 0.005 threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
xmr_payment = MagicMock()
|
||||
xmr_payment.id = "test-0.0045-xmr"
|
||||
xmr_payment.coin_type = "XMR"
|
||||
xmr_payment.shop = self.mock_shop
|
||||
xmr_payment.rate_locked_usd_per_coin = Decimal("420") # $420 per XMR
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "4TestXMRBelow"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with 0.00015 XMR payment (very tiny amount)
|
||||
expected_amount = Decimal("1.0")
|
||||
received_amount = Decimal("0.00015")
|
||||
|
||||
result = rescue.handle_underpayment(
|
||||
xmr_payment, expected_amount, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
# 0.00015 XMR - 9% fee = 0.00015 * 0.91 = 0.0001365 XMR
|
||||
self.assertEqual(result["refund_amount"], Decimal("0.0001365"))
|
||||
|
||||
# This should NOT be economically viable since:
|
||||
# 0.0001365 XMR * $420/XMR = $0.0573 USD < $0.069 threshold
|
||||
self.assertFalse(result["economically_viable"])
|
||||
|
||||
def test_xmr_economically_unviable_refund(self):
|
||||
"""Test XMR refund is marked unviable when amount is below threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
# Create XMR payment
|
||||
xmr_payment = MagicMock()
|
||||
xmr_payment.id = "xmr-micro-456"
|
||||
xmr_payment.coin_type = "XMR"
|
||||
xmr_payment.shop = self.mock_shop
|
||||
xmr_payment.rate_locked_usd_per_coin = Decimal("420") # $420 per XMR
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
# Mock refund address lookup
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "4XMRMicroRefund"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with tiny XMR amount (0.00014 XMR received)
|
||||
expected_amount = Decimal("1.0")
|
||||
received_amount = Decimal("0.00014") # Very tiny amount
|
||||
|
||||
result = rescue.handle_underpayment(
|
||||
xmr_payment, expected_amount, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
# Should return refund details with economically_viable=False
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "underpayment")
|
||||
|
||||
# 0.00014 XMR - 9% fee = 0.00014 * 0.91 = 0.0001274 XMR
|
||||
expected_refund = Decimal("0.0001274")
|
||||
self.assertEqual(result["refund_amount"], expected_refund)
|
||||
|
||||
# Key assertion: refund is marked as NOT economically viable
|
||||
# 0.0001274 XMR * $420/XMR = $0.0535 USD < $0.069 threshold
|
||||
self.assertFalse(result["economically_viable"])
|
||||
|
||||
def test_economically_viable_refund(self):
|
||||
"""Test refund is marked viable when amount is above threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
# Create DOGE payment with sufficient amount
|
||||
doge_payment = MagicMock()
|
||||
doge_payment.id = "doge-viable-123"
|
||||
doge_payment.coin_type = "DOGE"
|
||||
doge_payment.shop = self.mock_shop
|
||||
doge_payment.rate_locked_usd_per_coin = Decimal(
|
||||
"0.2343"
|
||||
) # 1 DOGE = $0.2343 (4.269 DOGE = $1)
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
# Mock refund address lookup
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "DRefundViable123"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with viable underpayment (0.5 DOGE received)
|
||||
expected_amount = Decimal("2.0")
|
||||
received_amount = Decimal("0.5")
|
||||
|
||||
result = rescue.handle_underpayment(
|
||||
doge_payment, expected_amount, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
# Should return refund details with economically_viable=True
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
# 0.5 DOGE - 9% fee = 0.5 * 0.91 = 0.455 DOGE
|
||||
expected_refund = Decimal("0.455")
|
||||
self.assertEqual(result["refund_amount"], expected_refund)
|
||||
|
||||
# Key assertion: refund IS economically viable
|
||||
self.assertTrue(result["economically_viable"])
|
||||
|
||||
def test_doge_expired_payment_economically_unviable_refund(self):
|
||||
"""Test expired DOGE payment refund is marked unviable when amount is below threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
# Create expired DOGE payment with micro amount
|
||||
expired_payment = MagicMock()
|
||||
expired_payment.id = "doge-expired-micro-123"
|
||||
expired_payment.coin_type = "DOGE"
|
||||
expired_payment.shop = self.mock_shop
|
||||
expired_payment.rate_locked_usd_per_coin = Decimal("0.2343") # 1 DOGE = $0.2343
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
# Mock refund address lookup
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "DExpiredMicro123"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with micro payment received after expiration (0.04 DOGE)
|
||||
received_amount = Decimal("0.04")
|
||||
|
||||
result = rescue.handle_expired_payment(
|
||||
expired_payment, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
# Should return refund details with economically_viable=False
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "expired")
|
||||
self.assertEqual(result["payment_id"], "doge-expired-micro-123")
|
||||
|
||||
# 0.04 DOGE - 9% fee = 0.04 * 0.91 = 0.0364 DOGE
|
||||
expected_refund = Decimal("0.0364")
|
||||
self.assertEqual(result["refund_amount"], expected_refund)
|
||||
|
||||
# Key assertion: expired payment refund is NOT economically viable
|
||||
self.assertFalse(result["economically_viable"])
|
||||
|
||||
def test_xmr_expired_payment_economically_unviable_refund(self):
|
||||
"""Test expired XMR payment refund is marked unviable when amount is below threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
# Create expired XMR payment
|
||||
xmr_payment = MagicMock()
|
||||
xmr_payment.id = "xmr-expired-micro-456"
|
||||
xmr_payment.coin_type = "XMR"
|
||||
xmr_payment.shop = self.mock_shop
|
||||
xmr_payment.rate_locked_usd_per_coin = Decimal("420") # $420 per XMR
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
# Mock refund address lookup
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "4XMRExpiredMicro"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with tiny XMR amount received after expiration (0.00014 XMR)
|
||||
received_amount = Decimal("0.00014") # Very tiny amount
|
||||
|
||||
result = rescue.handle_expired_payment(
|
||||
xmr_payment, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
# Should return refund details with economically_viable=False
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "expired")
|
||||
|
||||
# 0.00014 XMR - 9% fee = 0.00014 * 0.91 = 0.0001274 XMR
|
||||
expected_refund = Decimal("0.0001274")
|
||||
self.assertEqual(result["refund_amount"], expected_refund)
|
||||
|
||||
# Key assertion: expired payment refund is NOT economically viable
|
||||
# 0.0001274 XMR * $420/XMR = $0.0535 USD < $0.069 threshold
|
||||
self.assertFalse(result["economically_viable"])
|
||||
|
||||
def test_expired_payment_economically_viable_refund(self):
|
||||
"""Test expired payment refund is marked viable when amount is above threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
# Create expired DOGE payment with sufficient amount
|
||||
expired_payment = MagicMock()
|
||||
expired_payment.id = "doge-expired-viable-123"
|
||||
expired_payment.coin_type = "DOGE"
|
||||
expired_payment.shop = self.mock_shop
|
||||
expired_payment.rate_locked_usd_per_coin = Decimal("0.2343") # 1 DOGE = $0.2343
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
# Mock refund address lookup
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "DExpiredViable123"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with viable amount received after expiration (0.5 DOGE)
|
||||
received_amount = Decimal("0.5")
|
||||
|
||||
result = rescue.handle_expired_payment(
|
||||
expired_payment, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
# Should return refund details with economically_viable=True
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "expired")
|
||||
|
||||
# 0.5 DOGE - 9% fee = 0.5 * 0.91 = 0.455 DOGE
|
||||
expected_refund = Decimal("0.455")
|
||||
self.assertEqual(result["refund_amount"], expected_refund)
|
||||
|
||||
# Key assertion: expired payment refund IS economically viable
|
||||
self.assertTrue(result["economically_viable"])
|
||||
|
||||
def test_doge_payment_just_above_threshold(self):
|
||||
"""Test DOGE payment of 0.06 which is just above our 0.05 threshold."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
doge_payment = MagicMock()
|
||||
doge_payment.id = "test-0.06-doge"
|
||||
doge_payment.coin_type = "DOGE"
|
||||
doge_payment.shop = self.mock_shop
|
||||
doge_payment.rate_locked_usd_per_coin = Decimal("0.2343") # 1 DOGE = $0.2343
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "DRefundTest06"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Test with 0.06 DOGE payment
|
||||
expected_amount = Decimal("2.57356623")
|
||||
received_amount = Decimal("0.06")
|
||||
|
||||
result = rescue.handle_underpayment(
|
||||
doge_payment, expected_amount, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
# 0.06 DOGE - 9% fee = 0.06 * 0.91 = 0.0546 DOGE
|
||||
self.assertEqual(result["refund_amount"], Decimal("0.0546"))
|
||||
|
||||
# This should be economically viable since:
|
||||
# 0.0546 DOGE * $0.2343/DOGE = $0.0128 USD < $0.069 threshold
|
||||
# So it's actually NOT viable!
|
||||
self.assertFalse(result["economically_viable"])
|
||||
|
||||
def test_doge_doom_exact_scenario(self):
|
||||
"""Test the exact DOGE DOOM vulnerability scenario from production logs.
|
||||
|
||||
This reproduces walkeruin's attack:
|
||||
- Payment cb9f37bb received 0.04 DOGE vs expected 2.57356623 DOGE
|
||||
- System calculates refund of 0.0364 DOGE (customer) + 0.0036 DOGE (shop fee)
|
||||
- Total outputs (0.04 DOGE) cannot cover network fees (~0.005-0.008 DOGE)
|
||||
"""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
||||
# Create DOGE payment matching exact production scenario
|
||||
doge_payment = MagicMock()
|
||||
doge_payment.id = "cb9f37bb" # Actual payment ID from logs
|
||||
doge_payment.coin_type = "DOGE"
|
||||
doge_payment.shop = self.mock_shop
|
||||
doge_payment.rate_locked_usd_per_coin = Decimal("0.2343") # 1 DOGE = $0.2343
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
||||
# Mock refund address lookup
|
||||
with patch(
|
||||
"make_post_sell.lib.crypto_watcher.crypto_payment_rescue.get_user_crypto_refund_address"
|
||||
) as mock_get_addr:
|
||||
mock_refund_record = MagicMock()
|
||||
mock_refund_record.address = "DRefundAddressFromWalkeruin"
|
||||
mock_get_addr.return_value = mock_refund_record
|
||||
|
||||
# Exact values from the DOGE DOOM attack
|
||||
expected_amount = Decimal("2.57356623") # What was expected
|
||||
received_amount = Decimal("0.04") # What walkeruin sent
|
||||
|
||||
result = rescue.handle_underpayment(
|
||||
doge_payment, expected_amount, received_amount, self.mock_user
|
||||
)
|
||||
|
||||
# Should return refund details
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "underpayment")
|
||||
self.assertEqual(result["payment_id"], "cb9f37bb")
|
||||
|
||||
# Verify exact calculations from production logs
|
||||
# 0.04 DOGE - 9% fee = 0.04 * 0.91 = 0.0364 DOGE
|
||||
self.assertEqual(result["refund_amount"], Decimal("0.0364"))
|
||||
self.assertEqual(result["fee_amount"], Decimal("0.0036"))
|
||||
|
||||
# CRITICAL ASSERTION: This refund should NOT be economically viable
|
||||
# This is what prevents the DOGE DOOM infinite retry loop
|
||||
# 0.0364 DOGE * $0.2343/DOGE = $0.0085 USD < $0.069 threshold
|
||||
self.assertFalse(result["economically_viable"])
|
||||
|
||||
# Verify the refund USD value is below threshold
|
||||
refund_usd = result["refund_amount"] * Decimal("0.2343")
|
||||
self.assertLess(refund_usd, Decimal("0.069"))
|
||||
|
||||
# The total outputs (0.04 DOGE) can't cover network fees (~0.005-0.008 DOGE)
|
||||
# when split into two outputs. This is the core of the DOGE DOOM vulnerability
|
||||
|
||||
|
||||
class PaymentConfirmationOrderTests(unittest.TestCase):
|
||||
"""Unit tests for proper order of operations in payment confirmation."""
|
||||
|
|
@ -3470,6 +3877,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase):
|
|||
self.mock_payment.invoice = self.mock_invoice
|
||||
self.mock_payment.shop_sweep_to_address = "DShopSweepAddress123"
|
||||
self.mock_payment.refund_address = "DRefundAddress456"
|
||||
self.mock_payment.rate_locked_usd_per_coin = Decimal("0.2343") # $0.2343 per DOGE
|
||||
self.mock_payment.status = "received" # Not finalized yet
|
||||
self.mock_payment.current_confirmations = 2
|
||||
self.mock_payment.confirmations_required = 2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue