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:
parent
de757fdf76
commit
c3c05796da
4 changed files with 61 additions and 45 deletions
|
|
@ -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**:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue