Implement comprehensive refund confirmation system with 10-confirmation safety
REFUND SAFETY IMPROVEMENTS: - Refunds now require 10+ confirmations on incoming payment before execution - Added REFUND_PENDING_STATUSES for continued confirmation monitoring - Refund-pending payments stay in main queue until fully confirmed - Automatic retry of failed refunds once confirmations are met NEW STATUS TRANSITIONS: - confirmed-overpaid → confirmed-overpaid-refunded (when refund confirms) - Added refund_confirmations column to track outgoing refund TX status ENHANCED MONITORING: - Dual confirmation tracking: incoming payments + outgoing refunds - Refund retry queue detects when blocked refunds can proceed - Separate confirmation-only updates for refund-pending payments - Comprehensive logging for refund delays and confirmations SECURITY BENEFITS: - Prevents refunding payments subject to blockchain reorganizations - Eliminates double-spend attack vectors on underpaid/expired payments - Ensures refunds only sent for truly irreversible incoming payments - Maintains customer protection while maximizing merchant security This resolves the fundamental security issue where refunds could be sent for payments that might later be reversed due to insufficient confirmations.
This commit is contained in:
parent
581f2b2244
commit
49747420ff
3 changed files with 256 additions and 2 deletions
|
|
@ -174,6 +174,18 @@ class PaymentRescue:
|
|||
logger.info(f"Refund address: {refund_details['refund_address']}")
|
||||
logger.info(f"Refund reason: {refund_details['reason']}")
|
||||
|
||||
# Check if incoming payment has enough confirmations before allowing refund
|
||||
if payment and payment.current_confirmations < 10:
|
||||
logger.info(
|
||||
f"Refund delayed - incoming payment has {payment.current_confirmations}/10 confirmations"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Incoming payment needs {10 - payment.current_confirmations} more confirmations before refund",
|
||||
"refund_details": refund_details,
|
||||
"confirmations_needed": 10 - payment.current_confirmations,
|
||||
}
|
||||
|
||||
# Check wallet balance for the specific account before attempting refund
|
||||
account_index = payment.account_index if payment else 0
|
||||
logger.info(f"Checking balance for account {account_index}")
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,219 @@ def process_payment(
|
|||
env_request.dbsession.add(crypto_payment)
|
||||
|
||||
|
||||
def process_refund_confirmations(request, settings):
|
||||
"""
|
||||
Monitor refund transactions for confirmation status.
|
||||
Updates refund_confirmations and transitions status when fully confirmed.
|
||||
"""
|
||||
logger.info("Starting refund confirmation monitoring")
|
||||
|
||||
db = request.dbsession
|
||||
|
||||
# Query payments that need refund confirmation monitoring
|
||||
refund_queue = (
|
||||
db.query(CryptoPayment)
|
||||
.filter(
|
||||
CryptoPayment.refund_tx_hash.isnot(None),
|
||||
CryptoPayment.refund_confirmations < 10,
|
||||
CryptoPayment.status.in_(
|
||||
[
|
||||
CryptoPayment.STATUS_EXPIRED_REFUNDED,
|
||||
CryptoPayment.STATUS_UNDERPAID_REFUNDED,
|
||||
CryptoPayment.STATUS_CONFIRMED_OVERPAID, # Still pending refund confirmation
|
||||
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED,
|
||||
]
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not refund_queue:
|
||||
logger.debug("No refund transactions need confirmation monitoring")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(refund_queue)} refund transactions to monitor")
|
||||
|
||||
# Group by coin type to get appropriate clients
|
||||
refunds_by_coin = {}
|
||||
for payment in refund_queue:
|
||||
coin_type = payment.coin_type
|
||||
if coin_type not in refunds_by_coin:
|
||||
refunds_by_coin[coin_type] = []
|
||||
refunds_by_coin[coin_type].append(payment)
|
||||
|
||||
# Process each coin type
|
||||
for coin_type, coin_refunds in refunds_by_coin.items():
|
||||
logger.info(f"Monitoring {len(coin_refunds)} {coin_type} refund transactions")
|
||||
|
||||
try:
|
||||
client = get_crypto_client(settings, coin_type)
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to get {coin_type} client for refund monitoring: {e}")
|
||||
continue
|
||||
|
||||
for payment in coin_refunds:
|
||||
try:
|
||||
# Check if this is a payment that needs refund retry (no refund_tx_hash yet)
|
||||
if not payment.refund_tx_hash:
|
||||
logger.debug(
|
||||
f"Checking if refund can be retried for payment {payment.id}"
|
||||
)
|
||||
|
||||
# Check if incoming payment now has enough confirmations
|
||||
if payment.current_confirmations >= 10:
|
||||
logger.info(
|
||||
f"Retrying refund for payment {payment.id} - now has {payment.current_confirmations} confirmations"
|
||||
)
|
||||
|
||||
# Retry the refund process (import payment rescue here to avoid circular imports)
|
||||
from .crypto_payment_rescue import PaymentRescue
|
||||
|
||||
payment_rescue = PaymentRescue(request.dbsession, client)
|
||||
|
||||
# Determine refund type and retry
|
||||
if payment.status == CryptoPayment.STATUS_UNDERPAID_REFUNDED:
|
||||
# This was already marked as refunded but refund failed due to confirmations
|
||||
expected_amount = Decimal(
|
||||
payment.expected_amount
|
||||
) / Decimal("1e12")
|
||||
received_amount = Decimal(
|
||||
payment.received_amount
|
||||
) / Decimal("1e12")
|
||||
|
||||
refund_details = payment_rescue.handle_underpayment(
|
||||
payment, expected_amount, received_amount, payment.user
|
||||
)
|
||||
if refund_details:
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, payment
|
||||
)
|
||||
if result["success"]:
|
||||
payment.refund_tx_hash = result["tx_hash"]
|
||||
payment.refund_amount = int(
|
||||
refund_details["refund_amount"]
|
||||
* Decimal("1e12")
|
||||
)
|
||||
logger.info(
|
||||
f"Refund retry successful for payment {payment.id}: {result['tx_hash']}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"Checking refund confirmations for payment {payment.id}, TX: {payment.refund_tx_hash}"
|
||||
)
|
||||
|
||||
# Check transaction confirmation status
|
||||
if coin_type == "XMR":
|
||||
confirmations = get_monero_tx_confirmations(
|
||||
client, payment.refund_tx_hash
|
||||
)
|
||||
elif coin_type == "DOGE":
|
||||
confirmations = get_dogecoin_tx_confirmations(
|
||||
client, payment.refund_tx_hash
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unsupported coin type for refund monitoring: {coin_type}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Update confirmation count
|
||||
old_confirmations = payment.refund_confirmations
|
||||
payment.refund_confirmations = confirmations
|
||||
|
||||
if confirmations != old_confirmations:
|
||||
logger.info(
|
||||
f"Refund TX {payment.refund_tx_hash} confirmations: {old_confirmations} → {confirmations}"
|
||||
)
|
||||
|
||||
# Check if refund is now fully confirmed (10+ confirmations)
|
||||
if confirmations >= 10:
|
||||
old_status = payment.status
|
||||
|
||||
# Transition to final refunded status
|
||||
if payment.status == CryptoPayment.STATUS_CONFIRMED_OVERPAID:
|
||||
payment.status = (
|
||||
CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED
|
||||
)
|
||||
logger.info(
|
||||
f"Overpayment refund confirmed for payment {payment.id}: {old_status} → {payment.status}"
|
||||
)
|
||||
else:
|
||||
# Other refund types are already in final status, just log confirmation
|
||||
logger.info(
|
||||
f"Refund fully confirmed for payment {payment.id} (status: {payment.status})"
|
||||
)
|
||||
|
||||
# Update timestamp
|
||||
payment.updated_timestamp = int(time.time() * 1000)
|
||||
db.add(payment)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to check refund confirmations for payment {payment.id}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
def get_monero_tx_confirmations(client, tx_hash):
|
||||
"""Get confirmation count for a Monero transaction."""
|
||||
try:
|
||||
# Use get_transfer_by_txid to get transaction details
|
||||
result = client._call("get_transfer_by_txid", {"txid": tx_hash})
|
||||
if result and "transfer" in result:
|
||||
return result["transfer"].get("confirmations", 0)
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get Monero TX confirmations for {tx_hash}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def get_dogecoin_tx_confirmations(client, tx_hash):
|
||||
"""Get confirmation count for a Dogecoin transaction."""
|
||||
try:
|
||||
result = client.gettransaction(tx_hash)
|
||||
return result.get("confirmations", 0)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get Dogecoin TX confirmations for {tx_hash}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def update_payment_confirmations_only(client, crypto_payment, coin_type):
|
||||
"""
|
||||
Update confirmation count for refund-pending payments without full processing.
|
||||
This ensures refunds can be retried once incoming payments have 10+ confirmations.
|
||||
"""
|
||||
try:
|
||||
if coin_type == "XMR":
|
||||
# Query transfers for the payment subaddress
|
||||
res = (
|
||||
client.get_transfers_for_subaddr(
|
||||
crypto_payment.account_index,
|
||||
[crypto_payment.subaddress_index],
|
||||
)
|
||||
or {}
|
||||
)
|
||||
incoming = res.get("in", []) or []
|
||||
|
||||
# Calculate total confirmations from incoming transfers
|
||||
if incoming:
|
||||
min_confirmations = min(tx.get("confirmations", 0) for tx in incoming)
|
||||
crypto_payment.current_confirmations = min_confirmations
|
||||
logger.debug(
|
||||
f"Updated confirmations for refund-pending payment {crypto_payment.id}: {min_confirmations}"
|
||||
)
|
||||
|
||||
elif coin_type == "DOGE":
|
||||
# Update Dogecoin confirmation logic here if needed
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to update confirmations for refund-pending payment {crypto_payment.id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def run_once(env, interval):
|
||||
request = env["request"]
|
||||
settings = request.registry.settings
|
||||
|
|
@ -1183,10 +1396,15 @@ def run_once(env, interval):
|
|||
with request.tm:
|
||||
db = request.dbsession
|
||||
|
||||
# Include both active payments and refund-pending payments that need confirmation monitoring
|
||||
all_monitored_statuses = (
|
||||
CryptoPayment.ACTIVE_STATUSES + CryptoPayment.REFUND_PENDING_STATUSES
|
||||
)
|
||||
|
||||
q = (
|
||||
db.query(CryptoPayment)
|
||||
.filter(
|
||||
CryptoPayment.status.in_(CryptoPayment.ACTIVE_STATUSES),
|
||||
CryptoPayment.status.in_(all_monitored_statuses),
|
||||
CryptoPayment.swept_tx_hash.is_(
|
||||
None
|
||||
), # Only process payments that are not swept
|
||||
|
|
@ -1224,11 +1442,19 @@ def run_once(env, interval):
|
|||
|
||||
# Skip if payment status changed (another process may have handled it)
|
||||
db.refresh(crypto_payment)
|
||||
if crypto_payment.status not in CryptoPayment.ACTIVE_STATUSES:
|
||||
if crypto_payment.status not in all_monitored_statuses:
|
||||
logger.info(
|
||||
f"Skipping payment {crypto_payment.id} - status changed to {crypto_payment.status}"
|
||||
)
|
||||
continue
|
||||
|
||||
# For refund-pending payments, only update confirmations (don't do full processing)
|
||||
if crypto_payment.status in CryptoPayment.REFUND_PENDING_STATUSES:
|
||||
logger.debug(
|
||||
f"Updating confirmations for refund-pending payment {crypto_payment.id}"
|
||||
)
|
||||
update_payment_confirmations_only(client, crypto_payment, coin_type)
|
||||
continue
|
||||
# Query transfers for the payment address
|
||||
if coin_type == "XMR":
|
||||
# Monero: Query transfers for subaddress
|
||||
|
|
@ -1325,6 +1551,9 @@ def run_once(env, interval):
|
|||
exc_info=True,
|
||||
)
|
||||
|
||||
# Process refund confirmation monitoring
|
||||
process_refund_confirmations(request, settings)
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
args = parse_args(argv)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class CryptoPayment(RBase, Base):
|
|||
STATUS_RECEIVED = "received"
|
||||
STATUS_CONFIRMED = "confirmed"
|
||||
STATUS_CONFIRMED_OVERPAID = "confirmed-overpaid"
|
||||
STATUS_CONFIRMED_OVERPAID_REFUNDED = "confirmed-overpaid-refunded"
|
||||
STATUS_EXPIRED = "expired"
|
||||
STATUS_EXPIRED_REFUNDED = "expired-refunded"
|
||||
STATUS_UNDERPAID_REFUNDED = "underpaid-refunded"
|
||||
|
|
@ -41,11 +42,19 @@ class CryptoPayment(RBase, Base):
|
|||
STATUS_CONFIRMED_OVERPAID,
|
||||
]
|
||||
|
||||
# Statuses that need confirmation monitoring but may have refunds pending
|
||||
REFUND_PENDING_STATUSES = [
|
||||
STATUS_EXPIRED_REFUNDED, # Refund sent, but may need more incoming confirmations
|
||||
STATUS_UNDERPAID_REFUNDED, # Refund sent, but may need more incoming confirmations
|
||||
STATUS_OUT_OF_STOCK_REFUNDED, # Refund sent, but may need more incoming confirmations
|
||||
]
|
||||
|
||||
# Terminal statuses that should not be processed
|
||||
TERMINAL_STATUSES = [
|
||||
STATUS_EXPIRED,
|
||||
STATUS_EXPIRED_REFUNDED,
|
||||
STATUS_UNDERPAID_REFUNDED,
|
||||
STATUS_CONFIRMED_OVERPAID_REFUNDED,
|
||||
STATUS_CANCELLED,
|
||||
STATUS_OUT_OF_STOCK_REFUNDED,
|
||||
STATUS_NO_REFUND,
|
||||
|
|
@ -55,6 +64,7 @@ class CryptoPayment(RBase, Base):
|
|||
REFUND_REDIRECT_STATUSES = [
|
||||
STATUS_EXPIRED_REFUNDED, # Late payment refunded with 9% fee
|
||||
STATUS_UNDERPAID_REFUNDED, # Partial payment refunded with 9% fee
|
||||
STATUS_CONFIRMED_OVERPAID_REFUNDED, # Overpayment excess refunded with 9% fee
|
||||
STATUS_OUT_OF_STOCK_REFUNDED, # Out of stock - full refund (no fee)
|
||||
STATUS_NO_REFUND, # No refund possible (no address configured)
|
||||
]
|
||||
|
|
@ -113,6 +123,9 @@ class CryptoPayment(RBase, Base):
|
|||
Unicode(128), nullable=True
|
||||
) # Transaction hash of the refund
|
||||
refund_amount = Column(BigInteger, nullable=True) # Amount refunded in atomic units
|
||||
refund_confirmations = Column(
|
||||
Integer, nullable=False, default=0
|
||||
) # Current confirmation count of refund transaction
|
||||
|
||||
# Sweep tracking
|
||||
swept_amount = Column(BigInteger, nullable=True) # Amount swept in atomic units
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue