Remove pooled sweep behavior and implement individual payment sweeping

- Remove pooled_sweep logic from both Monero and Dogecoin sweep functions
- Each payment now swept individually for better accounting and audit trail
- Insufficient balance now returns False to retry later instead of fake pooled_sweep
- Update all tests to expect new behavior (False for insufficient balance)
- Update documentation to reflect 1:1 payment-to-sweep mapping
- Add link to refund address configuration in crypto checkout template
- All crypto watcher tests now passing with new individual sweep logic
This commit is contained in:
Russell Ballestrini 2025-09-24 16:03:50 -04:00
parent de757fdf76
commit c3c05796da
4 changed files with 61 additions and 45 deletions

View file

@ -261,9 +261,9 @@ General Solutions Architecture
- Uses "sweep_all" to transfer entire account balance to cold wallet
- Each shop has dedicated Monero account (prevents cross-shop fund mixing)
- Account isolation: Shop A uses account 0, Shop B uses account 1, etc.
- Multiple payments to same shop pool in their account until swept
- Not 1:1 payment-to-sweep mapping (more efficient, fewer transactions)
- Zero balance handling: If account already swept, payment marked as "pooled_sweep"
- Each payment swept individually using subaddress isolation
- 1:1 payment-to-sweep mapping for clear audit trail and accounting
- Insufficient balance handling: Payment retries later until funds unlock
- Failed sweeps are logged but don't block order fulfillment
**Per-Shop Risk Thresholds**:

View file

@ -113,7 +113,7 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None
if unlocked_balance < payment_amount_xmr:
logger.info(
f"Account unlocked balance ({unlocked_balance} XMR) is less than payment amount ({payment_amount_xmr} XMR) - funds still locked"
f"Account unlocked balance ({unlocked_balance} XMR) is less than payment amount ({payment_amount_xmr} XMR) - insufficient funds or funds still locked"
)
return False
@ -264,17 +264,12 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non
# Calculate payment amount in DOGE
payment_amount_doge = Decimal(crypto_payment.received_amount) / atomic_units
# If balance is too low, mark as already swept (another payment swept it)
# If balance is too low, return false to try again later
if balance < min_sweep_balance:
logger.info(
f"Balance too low to sweep for payment {crypto_payment.id} - marking as swept"
f"Balance too low to sweep for payment {crypto_payment.id} - insufficient funds"
)
crypto_payment.swept_amount = crypto_payment.received_amount
crypto_payment.swept_tx_hash = "pooled_sweep"
crypto_payment.swept_timestamp = now_timestamp()
if dbsession:
dbsession.add(crypto_payment)
return True
return False
# Get dynamic fee estimate for more accurate sweep amount
try:

View file

@ -487,8 +487,8 @@
<li><strong>Overpayment:</strong> Fulfilled & excess refunded minus fee</li>
<li><strong>Underpayment:</strong> Refunded minus fee</li>
<li><strong>Late payment:</strong> Refunded minus fee if sent after the minute expiry</li>
<li>Out of stock: Full refund (no fee - not your fault)</li>
<li>Wrong address: Cannot be recovered</li>
<li><strong>Out of stock:</strong> Full refund (no fee - not your fault)</li>
<li><strong>Wrong address:</strong> Cannot be recovered</li>
</ul>
{% endif %}
</div>
@ -501,14 +501,14 @@
{% else %}
<p>Without a refund address, all payment errors result in lost funds:</p>
<ul style="margin: 10px 0;">
<li>Underpayments are kept</li>
<li>Overpayments are kept</li>
<li>Late payments are kept</li>
<li>Out of stock: Cannot be recovered</li>
<li>Wrong address: Cannot be recovered</li>
<li><strong>Underpayments:</strong> Cannot be recovered</li>
<li><strong>Overpayments:</strong> Cannot be recovered</li>
<li><strong>Late payments:</strong> Cannot be recovered</li>
<li><strong>Out of stock:</strong> Cannot be recovered</li>
<li><strong>Wrong address:</strong> Cannot be recovered</li>
</ul>
{% endif %}
<p style="margin-top: 10px;">Consider cancelling and configuring a refund address for future purchases.</p>
<p style="margin-top: 10px;">Consider cancelling and <a href="/u/settings/crypto">configuring a refund address</a> for future purchases.</p>
</div>
{% endif %}
</section>

View file

@ -692,33 +692,44 @@ class AutoSweepTests(unittest.TestCase):
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet"
payment.account_index = 0
payment.subaddress_index = 1
payment.received_amount = 1000000000000 # 1 XMR expected
payment.is_swept = False
payment.coin_type = "XMR"
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result) # Returns True and marks as swept
self.assertFalse(result) # Returns False, will retry later
# Should only check balance, no sweep needed since balance is 0
# Should only check balance, no sweep attempted since balance is 0
mock_client._call.assert_called_once_with("get_balance", {"account_index": 0})
# Should mark payment as swept with pooled_sweep indicator
self.assertEqual(payment.swept_tx_hash, "pooled_sweep")
def test_auto_sweep_success(self):
"""Test successful auto-sweep."""
mock_client = MagicMock()
mock_client._call.side_effect = [
# First call - get_balance
{"unlocked_balance": 1000000000000}, # 1 XMR
# Second call - sweep_all
{"tx_hash": "sweep_tx_123", "amount_list": [1000000000000]},
# Second call - get_transfers (verify subaddress has transfers)
{
"in": [
{
"txid": "test_tx",
"amount": 500000000000,
"subaddr_index": {"major": 0, "minor": 1},
}
]
},
# Third call - test transfer for fee estimation
{"fee": 10000000000}, # 0.01 XMR fee
# Fourth call - actual transfer
{"tx_hash": "transfer_tx_123", "fee": 10000000000},
]
payment = MagicMock()
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet_address"
payment.account_index = 0
payment.subaddress_index = 1
payment.received_amount = 500000000000 # 0.5 XMR
payment.is_swept = False
payment.coin_type = "XMR"
@ -726,18 +737,20 @@ class AutoSweepTests(unittest.TestCase):
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result)
# Check sweep_all was called with correct parameters
sweep_call = mock_client._call.call_args_list[1]
self.assertEqual(sweep_call[0][0], "sweep_all")
sweep_params = sweep_call[0][1]
self.assertEqual(sweep_params["address"], "cold_wallet_address")
self.assertEqual(sweep_params["account_index"], 0)
# Check that transfer was called with correct parameters (4th call)
transfer_call = mock_client._call.call_args_list[3]
self.assertEqual(transfer_call[0][0], "transfer")
self.assertEqual(
transfer_call[0][1]["destinations"][0]["address"], "cold_wallet_address"
)
self.assertEqual(transfer_call[0][1]["account_index"], 0)
self.assertEqual(transfer_call[0][1]["subaddr_indices"], [1])
# Check sweep tracking fields were set
self.assertEqual(
payment.swept_amount, 1000000000000
) # Uses amount from amount_list
self.assertEqual(payment.swept_tx_hash, "sweep_tx_123")
payment.swept_amount, 490000000000
) # 0.5 XMR - 0.01 XMR fee = 0.49 XMR
self.assertEqual(payment.swept_tx_hash, "transfer_tx_123")
self.assertIsNotNone(payment.swept_timestamp)
def test_auto_sweep_already_swept(self):
@ -775,12 +788,20 @@ class AutoSweepTests(unittest.TestCase):
mock_client._call.side_effect = [
# First call - get_balance
{"unlocked_balance": 1000000000000}, # 1 XMR
# Second call - sweep_all
# Second call - get_transfers (verify subaddress has transfers)
{
"tx_hash": "sweep_tx_123",
"amount_list": [1000000000000],
"fee": 5000000000,
"in": [
{
"txid": "test_tx",
"amount": 500000000000,
"subaddr_index": {"major": 0, "minor": 1},
}
]
},
# Third call - test transfer for fee estimation
{"fee": 5000000000}, # 0.005 XMR fee
# Fourth call - actual transfer
{"tx_hash": "transfer_tx_123", "fee": 5000000000},
]
# Create a mock dbsession
@ -791,6 +812,7 @@ class AutoSweepTests(unittest.TestCase):
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet_address"
payment.account_index = 0
payment.subaddress_index = 1
payment.received_amount = 500000000000 # 0.5 XMR
payment.is_swept = False
payment.coin_type = "XMR"
@ -799,9 +821,9 @@ class AutoSweepTests(unittest.TestCase):
result = auto_sweep_payment(mock_client, payment, mock_dbsession)
self.assertTrue(result)
# Verify that swept_tx_hash was set
self.assertEqual(payment.swept_tx_hash, "sweep_tx_123")
self.assertEqual(payment.swept_amount, 1000000000000)
# 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_network_fee, 5000000000)
self.assertIsNotNone(payment.swept_timestamp)
@ -958,8 +980,7 @@ class DogecoinWatcherUnitTests(unittest.TestCase):
result = auto_sweep_payment_doge(mock_client, payment)
self.assertTrue(result) # Marked as swept (pooled sweep)
self.assertEqual(payment.swept_tx_hash, "pooled_sweep")
self.assertFalse(result) # Returns False, will retry later
mock_client.sendtoaddress.assert_not_called()
def test_auto_sweep_payment_dispatcher(self):