diff --git a/Makefile b/Makefile index 413de71..12cab45 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,7 @@ help: @echo "WALLET MANAGEMENT:" @echo " make sweep-check - Check hot wallet balances (dry run)" @echo " make sweep - Sweep funds to cold storage" + @echo " make sweep-all - Sweep ALL wallet funds to cold storage (dust collection)" @echo " make monero-transactions - View recent wallet transactions" @echo "" @echo "CLEANUP:" @@ -206,6 +207,52 @@ monero-transactions: -d '{"jsonrpc":"2.0","id":"0","method":"get_transfers","params":{"in":true,"out":true}}' | \ python3 -c "import sys, json; data = json.load(sys.stdin); print(json.dumps(data, indent=2))" +# Sweep ALL wallet funds to cold storage (dust collection) +sweep-all: venv config + @echo "=== SWEEPING ALL WALLET FUNDS TO COLD STORAGE ===" + @echo "CAUTION: This will send ALL unlocked balance from ALL accounts!" + @echo "" + @if [ -z "$${COLD_WALLET_ADDRESS}" ]; then \ + echo "ERROR: COLD_WALLET_ADDRESS environment variable not set!"; \ + echo "Usage: COLD_WALLET_ADDRESS= make sweep-all"; \ + exit 1; \ + fi + @echo "Checking ALL account balances..." + @all_balance=$$(curl --digest -u "test_user:test_pass" -s -X POST http://127.0.0.1:18083/json_rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"0","method":"get_balance","params":{"all_accounts":true}}' | \ + python3 -c "import sys, json; d=json.load(sys.stdin); result = d.get('result', {}); total_balance = result.get('balance', 0); total_unlocked = result.get('unlocked_balance', 0); accounts = result.get('per_subaddress', []); print('Total balance: {} atomic units ({:.12f} XMR)'.format(total_balance, total_balance/1e12)); print('Total unlocked: {} atomic units ({:.12f} XMR)'.format(total_unlocked, total_unlocked/1e12)); print('Per account breakdown:'); [print(' Account {}:{} - Balance: {:.12f} XMR (unlocked: {:.12f} XMR)'.format(acc.get('account_index', 0), acc.get('address_index', 0), acc.get('balance', 0)/1e12, acc.get('unlocked_balance', 0)/1e12)) for acc in accounts if acc.get('balance', 0) > 0]; print(total_unlocked)"); \ + total_unlocked=$$(echo "$$all_balance" | tail -1); \ + if [ -z "$$total_unlocked" ] || [ "$$total_unlocked" = "0" ]; then \ + echo "No unlocked balance to sweep across all accounts!"; \ + exit 1; \ + fi; \ + echo ""; \ + echo "Target address: $${COLD_WALLET_ADDRESS}"; \ + echo ""; \ + read -p "Are you sure you want to sweep ALL unlocked funds from ALL accounts? (yes/no): " confirm; \ + if [ "$$confirm" != "yes" ]; then \ + echo "Cancelled."; \ + exit 0; \ + fi; \ + echo ""; \ + echo "Getting all accounts for sweeping..."; \ + accounts=$$(curl --digest -u "test_user:test_pass" -s -X POST http://127.0.0.1:18083/json_rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"0","method":"get_accounts"}' | \ + python3 -c "import sys, json; d=json.load(sys.stdin); accounts = d.get('result', {}).get('subaddress_accounts', []); account_indices = [acc.get('account_index', 0) for acc in accounts]; print(','.join(map(str, account_indices)))"); \ + echo "Found accounts: $$accounts"; \ + echo ""; \ + for account in $$(echo $$accounts | tr ',' ' '); do \ + echo "Sweeping account $$account..."; \ + response=$$(curl --digest -u "test_user:test_pass" -s -X POST http://127.0.0.1:18083/json_rpc \ + -H 'Content-Type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"id\":\"0\",\"method\":\"sweep_all\",\"params\":{\"address\":\"$${COLD_WALLET_ADDRESS}\",\"account_index\":$$account,\"subaddr_indices\":[],\"priority\":0,\"get_tx_metadata\":false}}"); \ + echo "$$response" | python3 -c "import sys, json; d=json.load(sys.stdin); r=d.get('result',{}); amounts=r.get('amount_list',[]); fees=r.get('fee_list',[]); hashes=r.get('tx_hash_list',[]); [print('✓ Account $$account: {:.12f} XMR (fee: {:.12f}) Hash: {}'.format(amt/1e12, fee/1e12, tx_hash)) for amt, fee, tx_hash in zip(amounts, fees, hashes)] if amounts else print('Account $$account: No funds') if 'error' not in d else print('Account $$account:', d['error'].get('message','Error'))"; \ + done; \ + echo ""; \ + echo "All account sweep operations completed!" + # ----------------------------------------------------------------------------- # Monero Infrastructure Targets # ----------------------------------------------------------------------------- diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 88beb15..ba74ee1 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -16,10 +16,30 @@ from .mail import send_purchase_email, send_sale_email, send_refund_email from ..models.inventory import get_inventory_by_product_and_shop_location from .crypto_payment_rescue import PaymentRescue from ..models.meta import now_timestamp +from ..models.user_crypto_refund_address import UserCryptoRefundAddress logger = logging.getLogger(__name__) +def _get_user_refund_address(dbsession, user_id, coin_type): + """Get user's saved refund address for the given coin type.""" + if not user_id: + return None + + refund_address = ( + dbsession.query(UserCryptoRefundAddress) + .filter( + UserCryptoRefundAddress.user_id == user_id, + UserCryptoRefundAddress.coin_type == coin_type, + ) + .first() + ) + + if refund_address: + return refund_address.address + return None + + def _get_scan_position_from_semaphore(semaphore, coin_type): """Extract scan position from semaphore string. @@ -69,7 +89,7 @@ def _format_scan_semaphore(coin_type, position_value): return None -def _create_duplicate_payment(original_payment, tx, coin_type): +def _create_duplicate_payment(original_payment, tx, coin_type, dbsession=None): """ Create a new payment object for a duplicate transaction. @@ -101,6 +121,17 @@ def _create_duplicate_payment(original_payment, tx, coin_type): # DOGE/BTC amount is already in atomic units tx_amount = int(float(tx.get("amount", 0)) * 1e8) + # Determine refund address - use original payment's or lookup user's saved address + refund_address = original_payment.refund_address + if not refund_address and dbsession and original_payment.user_id: + refund_address = _get_user_refund_address( + dbsession, original_payment.user_id, coin_type + ) + if refund_address: + logger.info( + f"Using saved refund address for user {original_payment.user_id}: {refund_address[:16]}..." + ) + # Create new payment object for this duplicate using proper constructor duplicate_payment = CryptoPayment( invoice=None, # No invoice - this is a duplicate @@ -118,7 +149,7 @@ def _create_duplicate_payment(original_payment, tx, coin_type): confirmations_required=original_payment.confirmations_required, shop_location=original_payment.shop_location, shop_sweep_to_address=original_payment.shop_sweep_to_address, - refund_address=original_payment.refund_address, + refund_address=refund_address, ) # Override specific properties for duplicate @@ -983,7 +1014,21 @@ def process_payment( # Handle duplicate payment refunds (special case - always refund with 9% fee) if crypto_payment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND: - if incoming_transfers and payment_rescue and crypto_payment.refund_address: + # Check for refund address - use payment's or lookup saved address + refund_address = crypto_payment.refund_address + if not refund_address and crypto_payment.user_id: + refund_address = _get_user_refund_address( + env_request.dbsession, crypto_payment.user_id, crypto_payment.coin_type + ) + if refund_address: + # Update payment with the saved refund address + crypto_payment.refund_address = refund_address + env_request.dbsession.add(crypto_payment) + logger.info( + f"Updated duplicate payment {crypto_payment.id} with saved refund address: {refund_address[:16]}..." + ) + + if incoming_transfers and payment_rescue and refund_address: total_recv, _, _ = summarize_txs(incoming_transfers) if total_recv > 0: coin_config = get_coin_config(crypto_payment.coin_type) @@ -1498,7 +1543,10 @@ def process_payment( if duplicate_tx: # Create separate duplicate payment record duplicate_payment = _create_duplicate_payment( - crypto_payment, duplicate_tx, crypto_payment.coin_type + crypto_payment, + duplicate_tx, + crypto_payment.coin_type, + env_request.dbsession, ) env_request.dbsession.add(duplicate_payment) env_request.dbsession.flush() @@ -2278,7 +2326,7 @@ def scan_wallet_for_double_or_late_payments(request, settings): else: # Create new payment object for each duplicate transaction duplicate_payment = _create_duplicate_payment( - payment, tx, coin_type + payment, tx, coin_type, db ) db.add(duplicate_payment) db.flush() # Get the ID assigned diff --git a/make_post_sell/tests/test_double_spend_protection.py b/make_post_sell/tests/test_double_spend_protection.py index 73830c4..326146f 100644 --- a/make_post_sell/tests/test_double_spend_protection.py +++ b/make_post_sell/tests/test_double_spend_protection.py @@ -975,6 +975,57 @@ class TestEdgeCasesAndErrorHandling(unittest.TestCase): # This would be caught in the main processing loop # The payment would be skipped due to status change + def test_create_duplicate_payment_inherits_saved_refund_address(self): + """Test that duplicate payments inherit user's saved refund address when original has none.""" + from make_post_sell.lib.crypto_watcher import _create_duplicate_payment + from make_post_sell.models.user_crypto_refund_address import ( + UserCryptoRefundAddress, + ) + from unittest.mock import MagicMock + + original = self._create_test_payment() + original.refund_address = None # No refund address on original + original.user_id = "test-user-123" + + # Mock user and shop + mock_user = MagicMock() + mock_user.id = "test-user-123" + mock_shop = MagicMock() + mock_shop.id = "test-shop-456" + + # Create saved refund address for user + saved_address = UserCryptoRefundAddress( + user=mock_user, + shop=mock_shop, + coin_type="XMR", + address="saved_xmr_refund_address_12345", + ) + saved_address.user_id = "test-user-123" # Set ID directly for query + + # Mock the query to return our saved address + mock_query = MagicMock() + mock_filter = MagicMock() + mock_first = MagicMock(return_value=saved_address) + + self.request.dbsession.query = MagicMock(return_value=mock_query) + mock_query.filter = MagicMock(return_value=mock_filter) + mock_filter.first = mock_first + + tx = { + "txid": "duplicate-tx-456", + "amount": 75000000, # 0.075 XMR + "confirmations": 3, + } + + duplicate = _create_duplicate_payment( + original, tx, "XMR", self.request.dbsession + ) + + # Verify duplicate inherits saved refund address + self.assertEqual(duplicate.refund_address, "saved_xmr_refund_address_12345") + self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND) + self.assertEqual(duplicate.received_amount, 75000000) + if __name__ == "__main__": unittest.main()