diff --git a/docs/MONERO.rst b/docs/MONERO.rst index 253424e..80a3613 100644 --- a/docs/MONERO.rst +++ b/docs/MONERO.rst @@ -195,6 +195,88 @@ Why This Is Amazing - **Security by Design**: Account isolation prevents fund mixing - **Operational Excellence**: Automated deployment and monitoring +Subaddress Isolation and Refund Processing +=========================================== + +Our Monero implementation uses **subaddress isolation** to ensure complete security between customer payments and enable safe refund processing without affecting other transactions. + +**Account and Subaddress Hierarchy** +------------------------------------ +Each payment operates within a strict isolation model: + +.. code-block:: text + + Monero Wallet Structure: + ├── Account 0: System (unused) + ├── Account 1: Shop A + │ ├── Subaddress 0: Shop's primary address + │ ├── Subaddress 1: Customer Payment #1 + │ ├── Subaddress 2: Customer Payment #2 + │ └── Subaddress N: Customer Payment #N + ├── Account 2: Shop B + │ ├── Subaddress 0: Shop's primary address + │ ├── Subaddress 1: Customer Payment #1 + │ └── ... + └── Account N: Shop N... + +**Payment Isolation Security** +------------------------------ +Each crypto payment receives a unique subaddress within the shop's account, ensuring: + +- **Complete Financial Isolation**: Funds from different customers never mix +- **Targeted Refunds**: Specific payments can be refunded without affecting others +- **Safe Sweep Operations**: Only intended funds are moved during sweeps +- **Audit Trail**: Each payment's funds are traceable throughout the lifecycle + +**Refund Processing Flow** +-------------------------- +When refunds are required (expired quotes, out-of-stock items, overpayments): + +1. **Customer Refund**: Uses ``sweep_single`` to transfer customer's funds to their refund address +2. **Restocking Fee Sweep**: Uses ``sweep_all`` with specific ``subaddr_indices`` to collect restocking fees +3. **Transaction Ordering**: Refunds are always processed BEFORE restocking fee sweeps + +.. code-block:: python + + # Safe refund processing with subaddress isolation + + # Step 1: Refund customer (specific subaddress) + refund_result = monero_rpc.sweep_single( + address=crypto_payment.refund_address, + account_index=crypto_payment.account_index, + subaddr_indices=[crypto_payment.subaddress_index] + ) + + # Step 2: Sweep restocking fee (same subaddress) + restocking_result = monero_rpc.sweep_all( + address=shop_cold_wallet, + account_index=crypto_payment.account_index, + subaddr_indices=[crypto_payment.subaddress_index] + ) + +**Why sweep_all is Safe** +------------------------- +The ``sweep_all`` operation with ``subaddr_indices`` parameter ensures security: + +- **Without subaddr_indices**: Would sweep ALL funds in the account (DANGEROUS) +- **With subaddr_indices**: Only sweeps funds from specified subaddresses (SAFE) + +This allows us to: +- Collect restocking fees from specific failed payments +- Leave other customers' payments untouched +- Maintain complete isolation between transactions + +**Network Fee Considerations** +------------------------------ +Monero network fees are automatically calculated and deducted: + +- **Standard Fee**: ~0.0001 XMR for most transactions +- **Automatic Deduction**: Fees are subtracted from the swept amount +- **Fee Tracking**: All network fees are recorded for accounting +- **Confirmation Requirements**: Funds must be unlocked (~10 confirmations) before sweeping + +This subaddress isolation architecture ensures that our multi-tenant Monero system maintains enterprise-grade security while providing the flexibility needed for complex refund scenarios. + The Result: A truly **amazing** multi-tenant cryptocurrency cash register that makes Monero payments as easy as traditional payment processing, while maintaining the privacy, security, and decentralization that makes cryptocurrency revolutionary. **Welcome to the future of commerce.** 🚀 \ No newline at end of file diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 5b35b9f..30e2a48 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -360,13 +360,13 @@ def create_shop_context_request(env_request, crypto_payment: CryptoPayment): def check_inventory_availability(env_request, crypto_payment: CryptoPayment): """Check if all physical products in the invoice have sufficient inventory. - + Returns: tuple: (is_available: bool, out_of_stock_items: list) """ invoice = crypto_payment.invoice out_of_stock_items = [] - + if crypto_payment.shop_location: for item in invoice.line_items: product = item.product @@ -376,62 +376,81 @@ def check_inventory_availability(env_request, crypto_payment: CryptoPayment): ) available_qty = inv.quantity if inv else 0 if available_qty < item.quantity: - out_of_stock_items.append({ - 'product': product, - 'requested': item.quantity, - 'available': available_qty - }) - + out_of_stock_items.append( + { + "product": product, + "requested": item.quantity, + "available": available_qty, + } + ) + return len(out_of_stock_items) == 0, out_of_stock_items def finalize_invoice(env_request, crypto_payment: CryptoPayment): invoice: Invoice = crypto_payment.invoice - + # Check inventory availability for physical goods BEFORE finalizing - is_available, out_of_stock = check_inventory_availability(env_request, crypto_payment) - + is_available, out_of_stock = check_inventory_availability( + env_request, crypto_payment + ) + if not is_available: # Inventory not available - need to refund WITHOUT restocking fee logger.warning( f"Payment {crypto_payment.id} cannot be fulfilled - out of stock items: " f"{[item['product'].name for item in out_of_stock]}" ) - + # Set refund reason for out of stock crypto_payment.refund_reason = f"Out of stock: {', '.join([item['product'].name for item in out_of_stock])}" env_request.dbsession.add(crypto_payment) - - # Trigger full refund without restocking fee + + # Handle out of stock situation if crypto_payment.refund_address: - # Get the crypto client - client = get_crypto_client(env_request.registry.settings, crypto_payment.coin_type) + # Customer provided refund address - do full refund + client = get_crypto_client( + env_request.registry.settings, crypto_payment.coin_type + ) coin_config = get_coin_config(crypto_payment.coin_type) - + # Calculate full refund amount - refund_amount_crypto = Decimal(crypto_payment.received_amount) / coin_config["atomic_units"] - + refund_amount_crypto = ( + Decimal(crypto_payment.received_amount) / coin_config["atomic_units"] + ) + try: - # Process the refund sweep + # Process the refund sweep - use sweep_all to get ALL funds from this subaddress if crypto_payment.coin_type == "XMR": - sweep_result = client._call("sweep_single", { - "account_index": crypto_payment.account_index, - "address": crypto_payment.refund_address, - "amount": int(crypto_payment.received_amount), # Full amount, no fee deduction - "priority": 1, # Normal priority - "get_tx_hex": True, - "do_not_relay": False, - }) + sweep_result = client._call( + "sweep_all", + { + "account_index": crypto_payment.account_index, + "address": crypto_payment.refund_address, + "subaddr_indices": [ + crypto_payment.subaddress_index + ], # Only this payment's subaddress + "priority": 1, # Normal priority + "get_tx_hex": True, + "do_not_relay": False, + }, + ) tx_hash = sweep_result.get("tx_hash_list", [None])[0] elif crypto_payment.coin_type == "DOGE": - # For Dogecoin, use sendtoaddress - tx_hash = client._call("sendtoaddress", [ - crypto_payment.refund_address, - float(refund_amount_crypto) - ]) + # For Dogecoin, use sendtoaddress with fee subtraction for full refund + tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.refund_address, + float(refund_amount_crypto), + "Out of stock refund", # comment + "", # comment_to + True, # subtractfeefromamount - ensures ALL funds are sent + ], + ) else: tx_hash = None - + if tx_hash: crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED crypto_payment.refund_tx_hash = tx_hash @@ -442,20 +461,92 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment): f"TX: {tx_hash}" ) else: - logger.error(f"Failed to process full refund for payment {crypto_payment.id} - no tx hash") - + logger.error( + f"Failed to process full refund for payment {crypto_payment.id} - no tx hash" + ) + except Exception as e: - logger.error(f"Failed to process full refund for payment {crypto_payment.id}: {e}") + logger.error( + f"Failed to process full refund for payment {crypto_payment.id}: {e}" + ) else: - logger.warning( - f"Cannot refund out of stock payment {crypto_payment.id} - no refund address" - ) - + # No refund address - sweep all funds to shop's cold storage + if crypto_payment.shop_sweep_to_address: + client = get_crypto_client( + env_request.registry.settings, crypto_payment.coin_type + ) + coin_config = get_coin_config(crypto_payment.coin_type) + + try: + # Sweep all funds from this subaddress to shop's cold storage + if crypto_payment.coin_type == "XMR": + sweep_result = client._call( + "sweep_all", + { + "account_index": crypto_payment.account_index, + "address": crypto_payment.shop_sweep_to_address, + "subaddr_indices": [ + crypto_payment.subaddress_index + ], # Only this payment's subaddress + "priority": 1, # Normal priority + "get_tx_hex": True, + "do_not_relay": False, + }, + ) + tx_hash = sweep_result.get("tx_hash_list", [None])[0] + swept_amount = sweep_result.get("amount_list", [0])[0] + elif crypto_payment.coin_type == "DOGE": + # For Dogecoin, sweep ALL funds to shop's address + refund_amount_crypto = ( + Decimal(crypto_payment.received_amount) + / coin_config["atomic_units"] + ) + tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.shop_sweep_to_address, + float(refund_amount_crypto), + "Out of stock - no refund address", # comment + "", # comment_to + True, # subtractfeefromamount - ensures ALL funds are sent + ], + ) + swept_amount = crypto_payment.received_amount + else: + tx_hash = None + swept_amount = 0 + + if tx_hash: + crypto_payment.status = ( + CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND + ) + crypto_payment.swept_tx_hash = tx_hash + crypto_payment.swept_amount = swept_amount + crypto_payment.swept_timestamp = now_timestamp() + logger.info( + f"Out of stock payment {crypto_payment.id} swept to shop cold storage " + f"Amount: {swept_amount} atomic units TX: {tx_hash}" + ) + else: + logger.error( + f"Failed to sweep out of stock payment {crypto_payment.id} to shop storage" + ) + + except Exception as e: + logger.error( + f"Failed to sweep out of stock payment {crypto_payment.id} to shop: {e}" + ) + else: + logger.warning( + f"Out of stock payment {crypto_payment.id} has no refund address and no shop sweep address" + ) + crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND + # Send notification email about the refund if invoice.user.email: # TODO: Send out of stock refund notification email pass - + return False # Invoice not finalized # Unlock products for the purchasing user and notify via email (mirrors Stripe flow) @@ -561,45 +652,131 @@ def process_payment( total_recv, _, _ = summarize_txs(incoming_transfers) if total_recv > 0: # Check if items are out of stock - if so, do FULL refund - is_available, out_of_stock = check_inventory_availability(env_request, crypto_payment) - - if not is_available and crypto_payment.refund_address: - # Out of stock - do full refund + is_available, out_of_stock = check_inventory_availability( + env_request, crypto_payment + ) + + if not is_available: + # Out of stock - handle based on whether refund address exists crypto_payment.refund_reason = f"Expired + Out of stock: {', '.join([item['product'].name for item in out_of_stock])}" - + coin_config = get_coin_config(crypto_payment.coin_type) - refund_amount_crypto = Decimal(total_recv) / coin_config["atomic_units"] - - try: - if crypto_payment.coin_type == "XMR": - sweep_result = client._call("sweep_single", { - "account_index": crypto_payment.account_index, - "address": crypto_payment.refund_address, - "amount": int(total_recv), # Full amount - "priority": 1, - "get_tx_hex": True, - "do_not_relay": False, - }) - tx_hash = sweep_result.get("tx_hash_list", [None])[0] - elif crypto_payment.coin_type == "DOGE": - tx_hash = client._call("sendtoaddress", [ - crypto_payment.refund_address, - float(refund_amount_crypto) - ]) - else: - tx_hash = None - - if tx_hash: - crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED - crypto_payment.refund_tx_hash = tx_hash - crypto_payment.refund_amount = total_recv - logger.info( - f"Full refund for expired+out of stock payment {crypto_payment.id} " - f"Amount: {refund_amount_crypto} {crypto_payment.coin_type} TX: {tx_hash}" + refund_amount_crypto = ( + Decimal(total_recv) / coin_config["atomic_units"] + ) + + if crypto_payment.refund_address: + # Customer provided refund address - do full refund + try: + if crypto_payment.coin_type == "XMR": + # Use sweep_all to get ALL funds from this subaddress + sweep_result = client._call( + "sweep_all", + { + "account_index": crypto_payment.account_index, + "address": crypto_payment.refund_address, + "subaddr_indices": [ + crypto_payment.subaddress_index + ], # Only this payment's subaddress + "priority": 1, + "get_tx_hex": True, + "do_not_relay": False, + }, + ) + tx_hash = sweep_result.get("tx_hash_list", [None])[0] + elif crypto_payment.coin_type == "DOGE": + # Use subtractfeefromamount for full refund + tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.refund_address, + float(refund_amount_crypto), + "Out of stock refund", # comment + "", # comment_to + True, # subtractfeefromamount + ], + ) + else: + tx_hash = None + + if tx_hash: + crypto_payment.status = ( + CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED + ) + crypto_payment.refund_tx_hash = tx_hash + crypto_payment.refund_amount = total_recv + logger.info( + f"Full refund for expired+out of stock payment {crypto_payment.id} " + f"Amount: {refund_amount_crypto} {crypto_payment.coin_type} TX: {tx_hash}" + ) + except Exception as e: + logger.error( + f"Failed to process full refund for expired payment {crypto_payment.id}: {e}" ) - except Exception as e: - logger.error(f"Failed to process full refund for expired payment {crypto_payment.id}: {e}") - + else: + # No refund address - sweep to shop's cold storage + if crypto_payment.shop_sweep_to_address: + try: + if crypto_payment.coin_type == "XMR": + sweep_result = client._call( + "sweep_all", + { + "account_index": crypto_payment.account_index, + "address": crypto_payment.shop_sweep_to_address, + "subaddr_indices": [ + crypto_payment.subaddress_index + ], # Only this payment's subaddress + "priority": 1, + "get_tx_hex": True, + "do_not_relay": False, + }, + ) + tx_hash = sweep_result.get("tx_hash_list", [None])[ + 0 + ] + swept_amount = sweep_result.get("amount_list", [0])[ + 0 + ] + elif crypto_payment.coin_type == "DOGE": + # Sweep ALL funds to shop with fee subtraction + tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.shop_sweep_to_address, + float(refund_amount_crypto), + "Out of stock - no refund address", # comment + "", # comment_to + True, # subtractfeefromamount + ], + ) + swept_amount = total_recv + else: + tx_hash = None + swept_amount = 0 + + if tx_hash: + crypto_payment.status = ( + CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND + ) + crypto_payment.swept_tx_hash = tx_hash + crypto_payment.swept_amount = swept_amount + crypto_payment.swept_timestamp = now_timestamp() + logger.info( + f"Expired+out of stock payment {crypto_payment.id} swept to shop cold storage " + f"Amount: {swept_amount} atomic units TX: {tx_hash}" + ) + except Exception as e: + logger.error( + f"Failed to sweep expired+out of stock payment {crypto_payment.id}: {e}" + ) + else: + logger.warning( + f"Expired+out of stock payment {crypto_payment.id} has no refund or sweep address" + ) + crypto_payment.status = ( + CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND + ) + elif payment_rescue and crypto_payment.refund_address: # Not out of stock - do normal expired refund with restocking fee coin_config = get_coin_config(crypto_payment.coin_type) @@ -617,49 +794,76 @@ def process_payment( f"Refund executed for expired payment {crypto_payment.id}: TX {result['tx_hash']}" ) # Track the customer refund - crypto_payment.refund_amount = int(refund_details["refund_amount"] * coin_config["atomic_units"]) + crypto_payment.refund_amount = int( + refund_details["refund_amount"] + * coin_config["atomic_units"] + ) crypto_payment.refund_tx_hash = result["tx_hash"] crypto_payment.refund_reason = refund_details["reason"] - crypto_payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED - + crypto_payment.status = ( + CryptoPayment.STATUS_EXPIRED_REFUNDED + ) + # Commit the refund first env_request.dbsession.add(crypto_payment) env_request.dbsession.flush() - + # Now sweep the remaining restocking fee to shop's wallet (AFTER refund) - fee_amount = int(refund_details["fee_amount"] * coin_config["atomic_units"]) + fee_amount = int( + refund_details["fee_amount"] + * coin_config["atomic_units"] + ) if fee_amount > 0 and crypto_payment.shop_sweep_to_address: try: # Small delay to ensure refund tx is processed import time + time.sleep(2) - + if crypto_payment.coin_type == "XMR": - # Send exact restocking fee amount (9%) - sweep_result = client._call("transfer", { - "account_index": crypto_payment.account_index, - "destinations": [{ + # Sweep remaining balance from this specific subaddress only + sweep_result = client._call( + "sweep_all", + { + "account_index": crypto_payment.account_index, + "subaddr_indices": [ + crypto_payment.subaddress_index + ], "address": crypto_payment.shop_sweep_to_address, - "amount": fee_amount - }], - "priority": 1, - "get_tx_hex": True, - "do_not_relay": False, - }) - fee_tx_hash = sweep_result.get("tx_hash") - actual_swept = fee_amount + "priority": 1, + "get_tx_hex": True, + "do_not_relay": False, + }, + ) + fee_tx_hash = ( + sweep_result.get("tx_hash_list", [None])[0] + if sweep_result.get("tx_hash_list") + else None + ) + # Get actual amount swept (whatever remained after refund, minus network fees) + actual_swept = ( + sweep_result.get("amount_list", [0])[0] + if sweep_result.get("amount_list") + else 0 + ) elif crypto_payment.coin_type == "DOGE": # Send exact restocking fee amount - fee_crypto_amount = float(Decimal(fee_amount) / coin_config["atomic_units"]) - fee_tx_hash = client._call("sendtoaddress", [ - crypto_payment.shop_sweep_to_address, - fee_crypto_amount - ]) + fee_crypto_amount = float( + Decimal(fee_amount) + / coin_config["atomic_units"] + ) + fee_tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.shop_sweep_to_address, + fee_crypto_amount, + ], + ) actual_swept = fee_amount else: fee_tx_hash = None actual_swept = 0 - + if fee_tx_hash and actual_swept > 0: crypto_payment.swept_amount = actual_swept crypto_payment.swept_tx_hash = fee_tx_hash @@ -670,7 +874,9 @@ def process_payment( f"TX: {fee_tx_hash}" ) except Exception as e: - logger.error(f"Failed to sweep restocking fee for payment {crypto_payment.id}: {e}") + logger.error( + f"Failed to sweep restocking fee for payment {crypto_payment.id}: {e}" + ) else: logger.error( f"Refund failed for expired payment {crypto_payment.id}: {result['error']}" @@ -877,13 +1083,15 @@ def run_once(env, interval): with request.tm: db = request.dbsession - q = db.query(CryptoPayment).filter( - CryptoPayment.status.in_(CryptoPayment.ACTIVE_STATUSES), - CryptoPayment.swept_tx_hash.is_( - None - ), # Only process payments that are not swept - ).order_by( - CryptoPayment.created_timestamp.asc() # First come, first serve + q = ( + db.query(CryptoPayment) + .filter( + CryptoPayment.status.in_(CryptoPayment.ACTIVE_STATUSES), + CryptoPayment.swept_tx_hash.is_( + None + ), # Only process payments that are not swept + ) + .order_by(CryptoPayment.created_timestamp.asc()) # First come, first serve ) payments = q.all() @@ -913,7 +1121,7 @@ def run_once(env, interval): logger.info( f"Processing payment {crypto_payment.id} (status: {crypto_payment.status}, coin: {crypto_payment.coin_type})" ) - + # Skip if payment status changed (another process may have handled it) db.refresh(crypto_payment) if crypto_payment.status not in CryptoPayment.ACTIVE_STATUSES: @@ -942,18 +1150,18 @@ def run_once(env, interval): try: # Process payment with a savepoint for rollback capability savepoint = db.begin_nested() - + process_payment(request, crypto_payment, incoming, client) - + # Commit the savepoint if successful savepoint.commit() - + except Exception as e: # Rollback on any error savepoint.rollback() logger.error( f"Failed to process payment {crypto_payment.id}: {e}", - exc_info=True + exc_info=True, ) diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index f1e3624..e3bfb02 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -31,6 +31,7 @@ class CryptoPayment(RBase, Base): STATUS_UNDERPAID_REFUNDED = "underpaid-refunded" STATUS_CANCELLED = "cancelled" STATUS_OUT_OF_STOCK_REFUNDED = "out-of-stock-refunded" + STATUS_OUT_OF_STOCK_NO_REFUND = "out-of-stock-no-refund" # Active statuses that should be processed by the watcher ACTIVE_STATUSES = [ @@ -47,6 +48,7 @@ class CryptoPayment(RBase, Base): STATUS_UNDERPAID_REFUNDED, STATUS_CANCELLED, STATUS_OUT_OF_STOCK_REFUNDED, + STATUS_OUT_OF_STOCK_NO_REFUND, ] id = Column(UUIDType, primary_key=True, index=True) @@ -89,14 +91,16 @@ class CryptoPayment(RBase, Base): refund_address = Column( Unicode(256), nullable=True ) # Where to send refunds if payment fails/expires - + # Refund reason for audit trail refund_reason = Column( UnicodeText, nullable=True ) # Why the refund was issued (out of stock, expired, etc.) - + # Refund tracking - refund_tx_hash = Column(Unicode(128), nullable=True) # Transaction hash of the refund + refund_tx_hash = Column( + Unicode(128), nullable=True + ) # Transaction hash of the refund refund_amount = Column(BigInteger, nullable=True) # Amount refunded in atomic units # Sweep tracking