diff --git a/Makefile b/Makefile index ba0a29a..8eb4329 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,8 @@ help: @echo " make dogecoin-node - Start Dogecoin daemon (pruned mode)" @echo " make dogecoin-node-full - Start Dogecoin daemon (full blockchain)" @echo " make dogecoin-status - Check sync status and wallet info" + @echo " make dogecoin-check-fee - Check current transaction fee setting" + @echo " make dogecoin-fix-fee - Fix high transaction fee (sets to 0.001 DOGE)" @echo "" @echo "WALLET MANAGEMENT:" @echo " make sweep-check - Check hot wallet balances (dev tool)" @@ -180,9 +182,10 @@ http: venv @echo "Starting simple HTTP server on port 8000..." $(PYTHON) -m http.server 8000 -# Run the crypto watcher service for monitoring Monero payments. +# Run the crypto watcher service for monitoring crypto payments. crypto-watcher: venv config @echo "Starting crypto payment watcher..." + @echo "💡 TIP: If Dogecoin refunds fail, check fee: make dogecoin-check-fee" $(VENV_DIR)/bin/crypto_watcher $(DATA_DIR)/$(CONFIG_FILE) # Run the crypto watcher once (for testing or manual processing). @@ -564,6 +567,12 @@ dogecoin-config: check-dogecoin @echo "" >> $(HOME)/.dogecoin/dogecoin.conf @echo "# Hot wallet for Make Post Sell" >> $(HOME)/.dogecoin/dogecoin.conf @echo "wallet=make_post_sell_hot_wallet" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Transaction fee settings" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Set reasonable fee for multi-output transactions (sendmany)" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "paytxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Minimum fee per KB (prevents 0-fee transactions)" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "mintxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf @echo "" @echo "✓ Configuration created at: $(HOME)/.dogecoin/dogecoin.conf" @echo "" @@ -621,6 +630,37 @@ dogecoin-node-full: check-dogecoin -rpcallowip=127.0.0.1 \ -server=1 +# Check and fix Dogecoin transaction fee +dogecoin-check-fee: check-dogecoin + @echo "=== Checking Dogecoin Transaction Fee ===" + @if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \ + echo "❌ Dogecoin daemon not running"; \ + echo "Start with: make dogecoin-node"; \ + exit 1; \ + fi + @fee=$$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ','); \ + echo "Current transaction fee: $$fee DOGE"; \ + if [ "$$(echo "$$fee > 0.001" | bc -l)" = "1" ]; then \ + echo "⚠️ WARNING: Transaction fee is too high!"; \ + echo "This may cause sendmany (multi-output transactions) to fail."; \ + echo "Run 'make dogecoin-fix-fee' to set reasonable fee."; \ + else \ + echo "✓ Transaction fee is reasonable"; \ + fi + +# Set reasonable Dogecoin transaction fee +dogecoin-fix-fee: check-dogecoin + @echo "=== Setting Dogecoin Transaction Fee ===" + @if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \ + echo "❌ Dogecoin daemon not running"; \ + echo "Start with: make dogecoin-node"; \ + exit 1; \ + fi + @echo "Setting transaction fee to 0.001 DOGE..." + @dogecoin-cli settxfee 0.001 + @echo "✓ Transaction fee updated" + @echo "New fee: $$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ',') DOGE" + # Check Dogecoin sync status and wallet info dogecoin-status: check-dogecoin @echo "=== Dogecoin Node Status ===" diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 0d4c251..680c420 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -28,6 +28,13 @@ from ...models.user_crypto_refund_address import UserCryptoRefundAddress logger = logging.getLogger(__name__) +# Global confirmation requirements for outbound transactions (sweeps and refunds) +# These apply AFTER the payment is already confirmed incoming +OUTBOUND_CONFIRMATIONS_REQUIRED = { + "XMR": 10, # Monero: 10 confirmations for both sweeps and refunds + "DOGE": 2, # Dogecoin: 2 confirmations for both sweeps and refunds +} + class CryptoWatcherLogger: """Centralized logging helper for crypto watcher operations.""" @@ -542,8 +549,15 @@ def sweep_restocking_fee(settings, payment, refund_details, dbsession, context=" amount_to_send_crypto = float( Decimal(amount_to_send_atomic) / atomic_units ) - fee_tx_hash = client.sendtoaddress( - payment.shop_sweep_to_address, amount_to_send_crypto + fee_tx_hash = client._call( + "sendtoaddress", + [ + payment.shop_sweep_to_address, + amount_to_send_crypto, + "", # comment + "", # comment_to + True, # subtractfeefromamount + ], ) actual_swept = amount_to_send_atomic log.sweep_operation( @@ -864,14 +878,16 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None # Get dynamic fee estimate by doing a test transfer with do_not_relay=true try: # Estimate fee by doing a test transfer without broadcasting - # This gives accurate fee based on actual transaction size + # Use 96% of amount to leave room for fee in the same subaddress + test_amount_piconero = int(payment_amount_piconero * Decimal("0.96")) + test_transfer_result = client._call( "transfer", { "destinations": [ { "address": crypto_payment.shop_sweep_to_address, - "amount": payment_amount_piconero, + "amount": test_amount_piconero, } ], "account_index": crypto_payment.account_index, @@ -881,18 +897,24 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None "get_tx_metadata": True, }, ) - estimated_fee_piconero = int( + dynamic_fee_piconero = int( test_transfer_result.get("fee", 100000000000) ) # Fallback to ~0.0001 XMR + # Add 10% margin to the dynamic fee estimate to ensure sweep always works + estimated_fee_piconero = int(dynamic_fee_piconero * Decimal("1.1")) log.payment_info( crypto_payment, - f"Dynamic fee estimate: {estimated_fee_piconero / atomic_units} XMR (single transaction)", + f"Dynamic fee estimate: {dynamic_fee_piconero / atomic_units} XMR, using {estimated_fee_piconero / atomic_units} XMR (with 10% margin)", ) except Exception as e: - # Fallback to hardcoded fee if RPC call fails - estimated_fee_piconero = int(Decimal("0.0001") * atomic_units) + # Fallback based on historical data: actual fees ~0.0000306 XMR + # Use 3x multiplier for safety margin + estimated_fee_piconero = int( + Decimal("0.0000918") * atomic_units + ) # ~91,800,000 piconero (3x typical fee) log.error_with_context( - "Failed to get dynamic fee estimate, using fallback", e + f"Failed to get dynamic fee estimate, using 3x typical fee as fallback: {e}", + e, ) # Calculate transfer amount: payment minus fee and reserve for pending refunds @@ -940,8 +962,9 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None crypto_payment.swept_tx_hash = tx_hash crypto_payment.swept_timestamp = now_timestamp() crypto_payment.swept_network_fee = fee - # Update status to confirmed-complete after successful sweep - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + crypto_payment.swept_confirmations = 0 # Start tracking confirmations + # Status stays as CONFIRMED until sweep is confirmed + # crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE if dbsession: dbsession.add(crypto_payment) log.sweep_operation( @@ -1078,10 +1101,16 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non ) # Send the sweep transaction - tx_hash = client.sendtoaddress( - crypto_payment.shop_sweep_to_address, - float(sweep_amount), - f"Sweep for invoice {crypto_payment.invoice.id}", + # Use subtractfeefromamount=True so network fee is deducted from the sweep amount + tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.shop_sweep_to_address, + float(sweep_amount), + f"Sweep for invoice {crypto_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) if tx_hash: @@ -1093,8 +1122,9 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non crypto_payment.swept_timestamp = now_timestamp() # Note: DOGE RPC doesn't return fee info easily, so we leave it None crypto_payment.swept_network_fee = None - # Update status to confirmed-complete after successful sweep - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + crypto_payment.swept_confirmations = 0 # Start tracking confirmations + # Status stays as CONFIRMED until sweep is confirmed + # crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE if dbsession: dbsession.add(crypto_payment) @@ -2758,85 +2788,98 @@ def process_payment( env_request.dbsession, crypto_payment ) - # EARLY DETECTION: Check for overpayments on existing received payments + # Check if received payment has enough confirmations to become confirmed elif ( - payment_rescue - and crypto_payment.invoice - and crypto_payment.invoice.user - and crypto_payment.status - == CryptoPayment.STATUS_RECEIVED # Only check received status - and received_amount_int - > expected_amount_int # Check current received amount for overpayment + crypto_payment.status == CryptoPayment.STATUS_RECEIVED + and received_amount_int >= expected_amount_int # Has enough amount and early_min_confs >= int( crypto_payment.confirmations_required - ) # Wait for required confirmations + ) # Has enough confirmations ): - # Overpayment detected - check if it exceeds threshold - coin_config = get_coin_config(crypto_payment.coin_type) - atomic_units = coin_config["atomic_units"] - received_crypto = Decimal(received_amount_int) / atomic_units - expected_crypto = Decimal(expected_amount_int) / atomic_units - - log.payment_info( - crypto_payment, - f"EARLY DETECTION - Potential overpayment: " - f"received {received_crypto} {crypto_payment.coin_type}, expected {expected_crypto} {crypto_payment.coin_type} " - f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - checking threshold", - ) - - # Check if overpayment exceeds 5% threshold using PaymentRescue logic - refund_details = payment_rescue.handle_overpayment( - crypto_payment, - expected_crypto, - received_crypto, - crypto_payment.invoice.user, - ) - - if refund_details: - # Overpayment exceeds threshold - process refund + # Check for exact payment first + if received_amount_int == expected_amount_int: log.payment_info( crypto_payment, - f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}", - ) - - # Mark as confirmed with overpayment detected (refund pending) - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY - crypto_payment.refund_reason = refund_details["reason"] - - # Try refund immediately since we have enough confirmations - result = payment_rescue.execute_refund( - refund_details, crypto_payment - ) - if result["success"]: - log.payment_info( - crypto_payment, f"Excess refunded: TX {result['tx_hash']}" - ) - # Mark as confirmed with overpayment refunded - crypto_payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED - ) - crypto_payment.refund_tx_hash = result["tx_hash"] - crypto_payment.refund_confirmations = 0 # Just sent - - # Finalize invoice since core payment amount is sufficient - finalize_invoice(env_request, crypto_payment, send_emails=True) - - # Note: Restocking fee will be swept when refund is fully confirmed - # to avoid double-sweeping before refund transaction is safely confirmed - else: - log.payment_error( - crypto_payment, - f"Refund failed for overpayment: {result['error']}", - ) - else: - # Overpayment within acceptable threshold - just confirm normally - log.payment_info( - crypto_payment, - "EARLY DETECTION - Overpayment within 5% threshold - confirming normally", + f"Exact payment confirmed with {early_min_confs}/{crypto_payment.confirmations_required} confirmations", ) crypto_payment.status = CryptoPayment.STATUS_CONFIRMED finalize_invoice(env_request, crypto_payment, send_emails=True) + # EARLY DETECTION: Check for overpayments on existing received payments + elif ( + payment_rescue + and crypto_payment.invoice + and crypto_payment.invoice.user + and received_amount_int > expected_amount_int # Overpayment + ): + # Overpayment detected - check if it exceeds threshold + coin_config = get_coin_config(crypto_payment.coin_type) + atomic_units = coin_config["atomic_units"] + received_crypto = Decimal(received_amount_int) / atomic_units + expected_crypto = Decimal(expected_amount_int) / atomic_units + + log.payment_info( + crypto_payment, + f"EARLY DETECTION - Potential overpayment: " + f"received {received_crypto} {crypto_payment.coin_type}, expected {expected_crypto} {crypto_payment.coin_type} " + f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - checking threshold", + ) + + # Check if overpayment exceeds 5% threshold using PaymentRescue logic + refund_details = payment_rescue.handle_overpayment( + crypto_payment, + expected_crypto, + received_crypto, + crypto_payment.invoice.user, + ) + + if refund_details: + # Overpayment exceeds threshold - process refund + log.payment_info( + crypto_payment, + f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}", + ) + + # Mark as confirmed with overpayment detected (refund pending) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY + crypto_payment.refund_reason = refund_details["reason"] + + # Try refund immediately since we have enough confirmations + result = payment_rescue.execute_refund( + refund_details, crypto_payment + ) + if result["success"]: + log.payment_info( + crypto_payment, + f"Excess refunded: TX {result['tx_hash']}", + ) + # Mark as confirmed with overpayment refunded + crypto_payment.status = ( + CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED + ) + crypto_payment.refund_tx_hash = result["tx_hash"] + crypto_payment.refund_confirmations = 0 # Just sent + + # Finalize invoice since core payment amount is sufficient + finalize_invoice( + env_request, crypto_payment, send_emails=True + ) + + # Note: Restocking fee will be swept when refund is fully confirmed + # to avoid double-sweeping before refund transaction is safely confirmed + else: + log.payment_error( + crypto_payment, + f"Refund failed for overpayment: {result['error']}", + ) + else: + # Overpayment within acceptable threshold - just confirm normally + log.payment_info( + crypto_payment, + "EARLY DETECTION - Overpayment within 5% threshold - confirming normally", + ) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED + finalize_invoice(env_request, crypto_payment, send_emails=True) # Check for overpayment (exact match or overpaid) - for first payment elif ( @@ -3055,8 +3098,12 @@ def process_refund_confirmations(request, settings): if confirmations != old_confirmations: log.confirmation_update(payment, old_confirmations, confirmations) - # Check if refund is now fully confirmed (10+ confirmations) - if confirmations >= 10: + # Check if refund is now fully confirmed based on coin type requirements + required_confirmations = OUTBOUND_CONFIRMATIONS_REQUIRED.get( + coin_type, 10 + ) + + if confirmations >= required_confirmations: old_status = payment.status # Transition to final refunded status @@ -3314,6 +3361,123 @@ def update_payment_confirmations_only(client, crypto_payment, coin_type): ) +def process_sweep_confirmations(request, settings): + """ + Monitor sweep transactions for confirmation status. + Updates swept_confirmations and transitions status when fully confirmed. + """ + log.processing_cycle("Starting sweep confirmation monitoring") + db = request.dbsession + + # Query payments that need sweep confirmation monitoring + sweep_queue = [] + + # Query each coin type with its specific threshold + for coin_type, required_confirmations in OUTBOUND_CONFIRMATIONS_REQUIRED.items(): + coin_sweeps = ( + db.query(CryptoPayment) + .options( + sa.orm.joinedload(CryptoPayment.user), + sa.orm.joinedload(CryptoPayment.shop), + ) + .filter( + CryptoPayment.status == CryptoPayment.STATUS_CONFIRMED, + CryptoPayment.coin_type == coin_type, + CryptoPayment.swept_tx_hash != None, + CryptoPayment.swept_confirmations < required_confirmations, + ) + .all() + ) + sweep_queue.extend(coin_sweeps) + + if not sweep_queue: + log.processing_cycle("No sweep transactions need confirmation monitoring") + return + + log.processing_cycle("Found sweep transactions to monitor", len(sweep_queue)) + + # Group by coin type to get appropriate clients + sweeps_by_coin = {} + for payment in sweep_queue: + coin_type = payment.coin_type + if coin_type not in sweeps_by_coin: + sweeps_by_coin[coin_type] = [] + sweeps_by_coin[coin_type].append(payment) + + # Process each coin type + for coin_type, coin_sweeps in sweeps_by_coin.items(): + log.processing_cycle( + f"Monitoring {coin_type} sweep transactions", len(coin_sweeps) + ) + + try: + client = get_crypto_client(settings, coin_type) + except ValueError as e: + log.error_with_context( + f"Failed to get {coin_type} client for sweep monitoring", e + ) + continue + + for payment in coin_sweeps: + try: + # Get confirmation count for the sweep transaction + confirmations = 0 + if coin_type == "XMR": + confirmations = get_monero_tx_confirmations( + client, payment.swept_tx_hash + ) + elif coin_type == "DOGE": + confirmations = get_dogecoin_tx_confirmations( + client, payment.swept_tx_hash + ) + else: + log.payment_error( + payment, + f"Unsupported coin type for sweep monitoring: {coin_type}", + ) + continue + + # Update confirmation count + old_confirmations = payment.swept_confirmations + payment.swept_confirmations = confirmations + + if confirmations != old_confirmations: + log.payment_info( + payment, + f"Sweep confirmations: {old_confirmations} → {confirmations}", + ) + + # Check if sweep is now fully confirmed based on coin type requirements + required_confirmations = OUTBOUND_CONFIRMATIONS_REQUIRED.get( + coin_type, 10 + ) + + if confirmations >= required_confirmations: + old_status = payment.status + # Transition to confirmed-complete + payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + log.state_transition( + payment, + old_status, + payment.status, + f"sweep confirmed with {confirmations} confirmations", + ) + log.payment_info( + payment, + f"Sweep fully confirmed - funds have left hot wallet", + ) + + # Update timestamp + payment.updated_timestamp = int(time.time() * 1000) + db.add(payment) + + except Exception as e: + log.payment_error(payment, "Failed to check sweep confirmations", e) + continue + + log.processing_cycle("Finished sweep confirmation monitoring") + + def scan_wallet_for_double_or_late_payments(request, settings): """ Scan wallet for new incoming transactions since last scan position and match them to payments. @@ -4039,6 +4203,9 @@ def run_once(env, interval): # Process refund confirmation monitoring process_refund_confirmations(request, settings) + # Process sweep confirmation monitoring + process_sweep_confirmations(request, settings) + def main(argv=sys.argv): args = parse_args(argv) diff --git a/make_post_sell/lib/crypto_watcher/crypto_clients.py b/make_post_sell/lib/crypto_watcher/crypto_clients.py index 9702e30..1c24e9b 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_clients.py +++ b/make_post_sell/lib/crypto_watcher/crypto_clients.py @@ -300,9 +300,28 @@ class DogecoinClient: """Send Dogecoin to an address. Returns transaction ID.""" return self._call("sendtoaddress", [address, amount, comment]) - def sendmany(self, from_label: str, addresses_amounts: Dict[str, float]) -> str: - """Send to multiple addresses at once. More efficient for sweeping.""" - return self._call("sendmany", [from_label, addresses_amounts]) + def sendmany( + self, + from_label: str, + addresses_amounts: Dict[str, float], + minconf: int = 1, + comment: str = "", + ) -> str: + """Send to multiple addresses at once. More efficient for sweeping. + + Args: + from_label: Account label (use "" for default account) + addresses_amounts: Dict mapping addresses to amounts + minconf: Minimum confirmations (default: 1) + comment: Transaction comment (optional) + """ + # Build params list - Dogecoin expects specific parameter order + params = [from_label, addresses_amounts] + if minconf != 1 or comment: + params.append(minconf) + if comment: + params.append(comment) + return self._call("sendmany", params) # Blockchain Info diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index 87c7983..bc60a63 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -16,6 +16,11 @@ logger = logging.getLogger(__name__) RESTOCKING_FEE_PERCENT = Decimal("0.09") # 9% restocking fee OVERPAYMENT_THRESHOLD_PERCENT = Decimal("0.05") # 5% overpayment allowed before refund +# Dogecoin fee buffer - increase if you see "INSUFFICIENT FUNDS" errors in logs +# This accounts for network fees that Dogecoin adds on top of outputs +# Actual fees are typically 0.001-0.003 DOGE for 2-output transactions +DOGE_REFUND_FEE_BUFFER = 0.005 # Conservative buffer to avoid insufficient funds + def calculate_refund_amount(amount, fee_percent=RESTOCKING_FEE_PERCENT): """Calculate refund amount after deducting restocking fee.""" @@ -119,6 +124,7 @@ class PaymentRescue: "excess_amount": excess_amount, "refund_amount": refund_amount, "fee_amount": excess_amount - refund_amount, + "payment_amount": expected_amount, # Shop should get the actual payment too! "reason": f"Overpayment exceeds {int(OVERPAYMENT_THRESHOLD_PERCENT * 100)}% threshold: received {received_amount} but expected {expected_amount}", } @@ -159,9 +165,13 @@ class PaymentRescue: """ Execute the actual refund transaction. + Now implements multi-output transactions: + - Customer gets refund minus fee + - Shop owner gets the fee portion + Args: refund_details: dict with refund information - payment: CryptoPayment object (needed for account_index) + payment: CryptoPayment object (needed for account_index and shop_sweep_to_address) Returns: dict with transaction details or raises exception @@ -171,6 +181,9 @@ class PaymentRescue: logger = logging.getLogger(__name__) refund_amount_coin = refund_details["refund_amount"] + fee_amount_coin = refund_details["fee_amount"] + # For overpayments, also include the actual payment amount + payment_amount_coin = refund_details.get("payment_amount", Decimal("0")) # Get coin type and atomic units for proper logging coin_type = payment.coin_type if payment else "XMR" @@ -179,6 +192,8 @@ class PaymentRescue: coin_config = get_coin_config(coin_type) atomic_units = int(coin_config["atomic_units"]) refund_amount_atomic = int(refund_amount_coin * atomic_units) + fee_amount_atomic = int(fee_amount_coin * atomic_units) + payment_amount_atomic = int(payment_amount_coin * atomic_units) atomic_unit_name = ( "piconero" @@ -186,20 +201,21 @@ class PaymentRescue: else "koinu" if coin_type == "DOGE" else "atomic units" ) + # Get shop sweep address - we'll handle missing address with a clear error + shop_sweep_address = payment.shop_sweep_to_address if payment else None + if payment: logger.info(f"Attempting to refund: {payment}") - logger.info( - f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})" - ) - logger.info(f"Refund address: {refund_details['refund_address']}") - logger.info(f"Refund reason: {refund_details['reason']}") - else: - logger.info(f"Attempting to refund payment {refund_details['payment_id']}") - logger.info( - f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})" - ) - logger.info(f"Refund address: {refund_details['refund_address']}") - logger.info(f"Refund reason: {refund_details['reason']}") + if payment_amount_coin > 0: + logger.info( + f"Multi-output refund - Customer: {refund_amount_coin} {coin_type}, " + f"Shop: {payment_amount_coin + fee_amount_coin} {coin_type} (payment + fee)" + ) + else: + logger.info( + f"Refund: {refund_amount_coin} {coin_type} to customer, " + f"{fee_amount_coin} {coin_type} fee to shop" + ) # Check if incoming payment has enough confirmations before allowing refund from . import get_coin_config @@ -217,78 +233,179 @@ class PaymentRescue: - payment.current_confirmations, } - # Check wallet balance before attempting refund (coin-specific) - try: - if coin_type == "XMR": - account_index = payment.account_index if payment else 0 - logger.info(f"Checking Monero balance for account {account_index}") - balance_result = self.crypto_client._call( - "get_balance", {"account_index": account_index} - ) - unlocked_balance = balance_result.get("unlocked_balance", 0) - total_balance = balance_result.get("balance", 0) - logger.info( - f"Account {account_index} balance - Total: {total_balance} piconero ({Decimal(total_balance) / Decimal('1e12')} XMR)" - ) - logger.info( - f"Account {account_index} balance - Unlocked: {unlocked_balance} piconero ({Decimal(unlocked_balance) / Decimal('1e12')} XMR)" - ) - - if unlocked_balance < refund_amount_atomic: - logger.warning( - f"Insufficient unlocked balance in account {account_index}: need {refund_amount_atomic} but only have {unlocked_balance} unlocked" - ) - elif coin_type == "DOGE": - logger.info("Checking Dogecoin wallet balance") + # Get wallet balance for fee calculations + balance_result = None + if coin_type == "DOGE": + try: balance_result = self.crypto_client.getbalance() - logger.info(f"Dogecoin wallet balance: {balance_result} DOGE") - - if balance_result < refund_amount_coin: - logger.warning( - f"Insufficient Dogecoin balance: need {refund_amount_coin} DOGE but only have {balance_result} DOGE" - ) - except Exception as balance_error: - logger.warning(f"Could not check wallet balance: {balance_error}") + except Exception: + pass try: # Create coin-specific refund transaction if coin_type == "XMR": - # Monero uses the transfer RPC with atomic units (piconero) + # Monero always uses multi-output transfer + if not shop_sweep_address: + raise ValueError("Shop sweep address is required for refunds") + + account_index = payment.account_index if payment else 0 + + # Build destinations array + destinations = [ + { + "address": refund_details["refund_address"], + "amount": refund_amount_atomic, + } + ] + + # For overpayments, combine payment amount and fee into single shop output + shop_amount_atomic = fee_amount_atomic + if payment_amount_atomic > 0: + shop_amount_atomic += payment_amount_atomic + + destinations.append( + { + "address": shop_sweep_address, + "amount": shop_amount_atomic, + } + ) + transfer_params = { - "destinations": [ - { - "address": refund_details["refund_address"], - "amount": refund_amount_atomic, - } - ], + "destinations": destinations, "account_index": account_index, "get_tx_key": True, "do_not_relay": False, "priority": 1, } - logger.info(f"Calling Monero transfer with params: {transfer_params}") tx_result = self.crypto_client._call("transfer", transfer_params) elif coin_type == "DOGE": - # Dogecoin uses sendtoaddress RPC with coin amounts (not atomic units) + # Dogecoin always uses sendmany for multi-output + if not shop_sweep_address: + raise ValueError("Shop sweep address is required for refunds") + # Round to 8 decimal places to match DOGE precision requirements refund_amount_doge = round(float(refund_amount_coin), 8) - logger.info( - f"Calling Dogecoin sendtoaddress: {refund_amount_doge} DOGE to {refund_details['refund_address']}" + fee_amount_doge = round(float(fee_amount_coin), 8) + payment_amount_doge = ( + round(float(payment_amount_coin), 8) + if payment_amount_coin > 0 + else 0 ) - logger.info( - f"Dogecoin client config - URL: {self.crypto_client.rpc_url}, User: {self.crypto_client.rpc_user}" - ) - logger.info( - f"About to call sendtoaddress with params: address={refund_details['refund_address']}, amount={refund_amount_doge}" - ) - tx_hash = self.crypto_client.sendtoaddress( - refund_details["refund_address"], - refund_amount_doge, - f"Overpayment refund for {refund_details['payment_id']}", + + # Initial outputs - will be adjusted below if needed + outputs = {} + + # For overpayments, combine payment amount and fee into single shop output + shop_amount_doge = fee_amount_doge + if payment_amount_doge > 0: + shop_amount_doge = round(payment_amount_doge + fee_amount_doge, 8) + + # Account for network fee by reducing amounts proportionally + # Dogecoin sendmany adds fee on top of outputs, so we need to leave room + total_output = refund_amount_doge + shop_amount_doge + + # Fee estimate - can be overridden via environment variable + import os + + estimated_fee = float( + os.environ.get( + "DOGE_REFUND_FEE_BUFFER", str(DOGE_REFUND_FEE_BUFFER) + ) ) + if estimated_fee != DOGE_REFUND_FEE_BUFFER: + logger.info( + f"Using custom fee buffer from env: {estimated_fee} DOGE" + ) + + # Check if we need to adjust for fees + if balance_result and total_output + estimated_fee > balance_result: + # Calculate how much we need to reduce + shortage = (total_output + estimated_fee) - balance_result + + # Reduce both amounts proportionally + refund_ratio = refund_amount_doge / total_output + shop_ratio = shop_amount_doge / total_output + + # Round down to 3 decimal places + import math + + refund_amount_doge = ( + math.floor( + (refund_amount_doge - shortage * refund_ratio) * 1000 + ) + / 1000 + ) + shop_amount_doge = ( + math.floor((shop_amount_doge - shortage * shop_ratio) * 1000) + / 1000 + ) + + # Build final outputs with adjusted amounts + outputs[refund_details["refund_address"]] = refund_amount_doge + outputs[shop_sweep_address] = shop_amount_doge + + # Get the account for the payment address + from_account = "" + try: + from_account = self.crypto_client._call( + "getaccount", [payment.address] + ) + except Exception: + pass + + # Clean outputs - ensure plain floats + clean_outputs = { + addr: float(amount) for addr, amount in outputs.items() + } + + # Try sendmany with the account that has the funds + try: + tx_hash = self.crypto_client.sendmany( + from_account, clean_outputs, 1 + ) + except Exception as e: + error_msg = str(e).lower() + if "insufficient funds" in error_msg: + # Log detailed fee information when we hit insufficient funds + logger.error(f"INSUFFICIENT FUNDS - Fee estimate too low!") + logger.error(f"Current fee buffer: {estimated_fee} DOGE") + logger.error( + f"Total outputs: {sum(clean_outputs.values())} DOGE" + ) + logger.error(f"Available balance: {balance_result} DOGE") + shortfall = ( + sum(clean_outputs.values()) + estimated_fee - balance_result + ) + logger.error( + f"Shortfall: {shortfall:.8f} DOGE (may need more for actual network fee)" + ) + logger.error( + f"ACTION REQUIRED: Increase DOGE_REFUND_FEE_BUFFER constant at top of crypto_payment_rescue.py" + ) + + # Try with default account as fallback + try: + tx_hash = self.crypto_client.sendmany("", clean_outputs, 1) + except Exception as e2: + if "insufficient funds" in str(e2).lower(): + logger.error( + f"Both accounts failed - fee definitely too low!" + ) + raise e2 + else: + raise + tx_result = {"tx_hash": tx_hash} + # Log success with fee info for monitoring + logger.info(f"Refund sent successfully! TX: {tx_hash}") + if balance_result: + buffer_used = balance_result - sum(clean_outputs.values()) + logger.info( + f"Fee buffer used: {buffer_used:.8f} DOGE (estimated: {estimated_fee})" + ) + else: raise ValueError(f"Refund not supported for coin type: {coin_type}") diff --git a/make_post_sell/lib/render.py b/make_post_sell/lib/render.py index 58a7691..adb7f46 100644 --- a/make_post_sell/lib/render.py +++ b/make_post_sell/lib/render.py @@ -13,6 +13,8 @@ def make_cleaner_from_shop(shop): """Given a Shop return a bleach Cleaner object.""" cleaner = default_cleaner() cleaner.link_protection = True + # Store shop reference for link color styling + cleaner.shop = shop if shop.domain_name: apex_domain_name = shop.domain_name.split(".")[-2:] cleaner.whitelist_domains.append("makepostsell.com") diff --git a/make_post_sell/lib/sanitize_html.py b/make_post_sell/lib/sanitize_html.py index 71b6792..9533178 100644 --- a/make_post_sell/lib/sanitize_html.py +++ b/make_post_sell/lib/sanitize_html.py @@ -10,6 +10,8 @@ from bleach.callbacks import nofollow, target_blank from bleach_allowlist import markdown_tags, markdown_attrs, all_styles +# We implement our own CSS validation in protect_links() for security + from bs4 import BeautifulSoup import miniuri @@ -58,6 +60,11 @@ def default_cleaner(tag_acl=None): attrs["img"].append("width") attrs["img"].append("style") attrs["span"] = ["class"] + + # Allow style attribute on anchor tags for link color styling + if "a" not in attrs: + attrs["a"] = [] + attrs["a"].append("style") # Allow both whitelist and blacklist tag_name/attr/attr_value # to get past bleach. @@ -78,6 +85,8 @@ def default_cleaner(tag_acl=None): # attributes for the given tag_name. attrs[tag_name].append(attr_name) + # We allow style attributes and validate CSS in protect_links function + # This provides targeted validation for the specific CSS we add (color property) cleaner = Cleaner(tags=tags, attributes=attrs) # doesn't do anything, but i used to be able to pass it via constructor. @@ -172,6 +181,23 @@ def protect_links(soup, cleaner): for a_tag in soup.find_all("a"): uri = miniuri.Uri(a_tag.attrs.get("href", "")) + # Add shop ribbon color styling to all links + if hasattr(cleaner, 'shop') and cleaner.shop: + link_color = cleaner.shop.theme_link_color + if link_color: + # Validate that the color looks like a valid CSS color + # Allow hex colors (#fff, #ffffff), rgb(), rgba(), hsl(), hsla(), and named colors + import re + color_pattern = r'^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|hsla\([^)]+\)|[a-zA-Z]+)$' + if re.match(color_pattern, link_color.strip()): + # Get existing style or create new one + existing_style = a_tag.attrs.get("style", "") + if existing_style and not existing_style.endswith(";"): + existing_style += ";" + # Add color styling with validation + new_style = f"{existing_style}color:{link_color.strip()};" + a_tag.attrs["style"] = new_style + if uri.hostname in cleaner.whitelist_domains: # domain in whitelist or relative URI so remove rel="nofollow". a_tag.attrs.pop("rel", None) diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index b7905bd..b869f8f 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -219,6 +219,9 @@ class CryptoPayment(RBase, Base): swept_network_fee = Column( BigInteger, nullable=True ) # Network fee paid (in atomic units, from node) + swept_confirmations = Column( + Integer, nullable=False, default=0 + ) # Current confirmation count of sweep transaction # Current confirmation count current_confirmations = Column( diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index e980eb1..2e96fa2 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -288,7 +288,12 @@ class Invoice(RBase, Base): @property def is_paid(self): """Check if this invoice has been successfully paid.""" - return self.payment_status in ["confirmed", "confirmed_overpaid", "paid"] + return self.payment_status in [ + "confirmed", + "confirmed-overpay", + "confirmed-complete", + "paid", + ] def get_invoice_by_id(dbsession, invoice_id): @@ -354,7 +359,7 @@ def delete_invoice_by_id(dbsession, invoice_id): } # Guard: Don't delete successful payments - those are legitimate transactions - successful_statuses = ["confirmed", "confirmed-overpaid"] + successful_statuses = ["confirmed", "confirmed-overpay", "confirmed-complete"] if crypto_payment.status in successful_statuses: return { "success": False, diff --git a/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py new file mode 100644 index 0000000..52853a2 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py @@ -0,0 +1,34 @@ +"""Add swept_confirmations to track sweep transaction confirmations + +Revision ID: 0915b3ff883d +Revises: 07908c8c840d +Create Date: 2025-10-02 19:00:08.788508 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0915b3ff883d" +down_revision = "07908c8c840d" +branch_labels = None +depends_on = None + +from make_post_sell.models.meta import UUIDType + + +def upgrade(): + # Add swept_confirmations column to mps_crypto_payment table + op.add_column( + "mps_crypto_payment", + sa.Column( + "swept_confirmations", sa.Integer(), nullable=False, server_default="10" + ), + ) + + +def downgrade(): + # Remove swept_confirmations column from mps_crypto_payment table + op.drop_column("mps_crypto_payment", "swept_confirmations") diff --git a/make_post_sell/templates/crypto_checkout.j2 b/make_post_sell/templates/crypto_checkout.j2 index 7d46777..fceba94 100644 --- a/make_post_sell/templates/crypto_checkout.j2 +++ b/make_post_sell/templates/crypto_checkout.j2 @@ -58,7 +58,7 @@
Total Amount: {{ '%.12f' % amount_crypto }} {{ coin_symbol }}
Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}
Payment ID: {{ payment_id }}
Invoice ID: {{ invoice_id }}
{% endif %} {% else %} @@ -67,7 +67,7 @@Total Amount: {{ '%.8f' % amount_crypto }} {{ coin_symbol }}
Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}
Payment ID: {{ payment_id }}
Invoice ID: {{ invoice_id }}
{% endif %} {% endif %} @@ -191,7 +191,7 @@ const currentStatus = statusEl ? statusEl.textContent.trim() : ''; // Use smart redirect URL for confirmed states, crypto-quotes for terminal states - if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { invoiceLink.href = redirectUrl || '/u/purchases'; // Determine button text based on redirect URL if (redirectUrl && redirectUrl.includes('/p/')) { @@ -250,7 +250,7 @@ if (expiresEl) { expiresEl.parentNode.style.display = 'none'; } - } else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-overpaid')) { + } else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-complete' || lastKnownStatus === 'confirmed-overpay')) { // Payment confirmed - replace with invoice link replaceButtonsWithInvoiceLink(); @@ -377,7 +377,7 @@ console.log(`Status changed from ${lastKnownStatus} to ${currentStatus}`); // Show confirmations section when payment is received or later - if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { if (confirmationsEl) { confirmationsEl.style.display = 'block'; } @@ -399,7 +399,7 @@ if (expiresEl && expiresEl.parentNode) { expiresEl.parentNode.style.display = 'none'; } - } else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + } else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { // Payment confirmed - replace with smart redirect link replaceButtonsWithInvoiceLink(data.redirect_url); @@ -410,7 +410,7 @@ // Redirect only if status changed from non-confirmed to confirmed (via polling) // Don't redirect if user browsed directly to already-confirmed quote - if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-overpaid') { + if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-complete' && lastKnownStatus !== 'confirmed-overpay') { setTimeout(() => { // Use smart redirect URL if available, otherwise fallback to purchases page window.location.href = data.redirect_url || '/u/purchases'; @@ -470,7 +470,7 @@ // Stop polling if we've reached a terminal status or confirmed status const currentStatus = statusEl ? statusEl.textContent.trim() : ''; - if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed') { + if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete') { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; diff --git a/make_post_sell/templates/crypto_quotes_history.j2 b/make_post_sell/templates/crypto_quotes_history.j2 index 4557baa..118f495 100644 --- a/make_post_sell/templates/crypto_quotes_history.j2 +++ b/make_post_sell/templates/crypto_quotes_history.j2 @@ -58,7 +58,7 @@ {% endif %}