Fix duplicate payment refunds by inheriting user's saved refund address

- Add UserCryptoRefundAddress import and lookup function
- Update _create_duplicate_payment to lookup saved refund address
- Fix refund processing to check saved addresses if not set on payment
- Add test for refund address inheritance
- Add Makefile target for sweeping all wallet funds (sweep-all)

This fixes the issue where duplicate payments weren't being refunded
because they had no refund_address set. Now the system will:
1. First check if original payment has refund_address
2. If not, lookup user's saved refund address for that coin type
3. Use the found address for the duplicate payment record

Also added sweep-all target to collect dust from all accounts.
This commit is contained in:
Russell Ballestrini 2025-09-28 08:35:08 -04:00
parent 7a1dcfb7e4
commit 30a5088ea0
3 changed files with 151 additions and 5 deletions

View file

@ -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=<your-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
# -----------------------------------------------------------------------------

View file

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

View file

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