diff --git a/api/app/btcpay_routes.py b/api/app/btcpay_routes.py index ed9df65..d70fa4c 100644 --- a/api/app/btcpay_routes.py +++ b/api/app/btcpay_routes.py @@ -2,7 +2,7 @@ from __future__ import annotations import uuid from datetime import datetime, timedelta, timezone -from decimal import Decimal, ROUND_DOWN +from decimal import Decimal, ROUND_DOWN, ROUND_UP from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -142,8 +142,12 @@ def _resolve_btcpay_amount( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Fiat quote service unavailable", ) from exc + # Round UP to 5 decimal places so we never undercharge. + # 5 decimals = 0.00001 XMR ≈ $0.002 at $200/XMR — more than enough precision. + # See also: routes.py _resolve_invoice_amount() for the same rounding. + # To change precision: adjust the Decimal quantize string here and in routes.py. amount_xmr = (Decimal(amount) / quote.rate).quantize( - Decimal("0.000000000001"), rounding=ROUND_DOWN + Decimal("0.00001"), rounding=ROUND_UP ) quote_payload = { "fiat_amount": str(amount), @@ -213,7 +217,11 @@ def _btcpay_amount_currency(invoice: Invoice) -> tuple[str, str, dict[str, Any]] def _format_xmr_fixed(value: Decimal) -> str: - return f"{value.quantize(Decimal('0.000000000001')):.12f}" + """Format XMR amount with trailing zeros stripped.""" + formatted = f"{value.quantize(Decimal('0.000000000001')):.12f}" + if "." in formatted: + formatted = formatted.rstrip("0").rstrip(".") + return formatted def _atomic_to_xmr(value: int) -> Decimal: diff --git a/api/app/btcpay_webhooks.py b/api/app/btcpay_webhooks.py index b172193..8a48005 100644 --- a/api/app/btcpay_webhooks.py +++ b/api/app/btcpay_webhooks.py @@ -56,33 +56,49 @@ def dispatch_btcpay_webhooks( "Content-Type": "application/json", "User-Agent": "xmrcheckout-btcpay-webhook/1.0", } - response = _post_with_redirects( - hook.url, - data=body, - headers=headers, - timeout=5, - ) - if response is None: + # Retry up to 3 times with backoff on transient failures + last_response = None + for attempt in range(3): + try: + last_response = _post_with_redirects( + hook.url, + data=body, + headers=headers, + timeout=10, + ) + if last_response is not None and last_response.status_code < 500: + break + except RequestException: + if attempt == 2: + raise + if attempt < 2: + time.sleep(1.0 * (attempt + 1)) + if last_response is None: + logger.warning( + "BTCPay webhook delivery failed after retries (redirect loop)", + extra={"webhook_id": str(hook.id), "event": event_type, "url": hook.url}, + ) continue - if response.status_code >= 400: + if last_response.status_code >= 400: logger.warning( "BTCPay webhook delivered non-success status", extra={ "webhook_id": str(hook.id), "event": event_type, - "http_status": response.status_code, + "http_status": last_response.status_code, }, ) except RequestException as exc: - logger.warning( - "BTCPay webhook delivery failed", - extra={"webhook_id": str(hook.id), "event": event_type}, + logger.error( + "BTCPay webhook delivery failed after 3 attempts: %s", + exc, + extra={"webhook_id": str(hook.id), "event": event_type, "url": hook.url}, ) - logger.debug("BTCPay webhook delivery error: %s", exc) except Exception as exc: - logger.warning( - "BTCPay webhook dispatch failed", - extra={"webhook_id": str(hook.id), "event": event_type, "error": str(exc)}, + logger.error( + "BTCPay webhook dispatch failed: %s", + exc, + extra={"webhook_id": str(hook.id), "event": event_type}, ) diff --git a/api/app/monero_service.py b/api/app/monero_service.py index e4bb594..e28d2cc 100644 --- a/api/app/monero_service.py +++ b/api/app/monero_service.py @@ -380,7 +380,9 @@ class MoneroWalletService: ) except (RPCError, RequestException): try: - restore_height = self._daemon_height() or 0 + # Look back ~2 days so new wallets can find recent payments + daemon_height = self._daemon_height() or 0 + restore_height = max(0, daemon_height - 1440) gen_start = time.monotonic() # generate_from_keys opens the wallet when successful. backend.client.raw_request( diff --git a/api/app/reconciler.py b/api/app/reconciler.py index 69806be..d949c99 100644 --- a/api/app/reconciler.py +++ b/api/app/reconciler.py @@ -96,116 +96,120 @@ def _reconcile_invoices(service: MoneroWalletService) -> None: transfers = _get_transfers_with_retry( service, user, invoice.address, ) - except Exception as exc: - logger.warning( - "Skipping invoice reconcile due to wallet RPC error", - extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, - ) - logger.debug("Wallet RPC error: %s", exc) - db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id}) - continue - total_atomic = 0 - max_confirmations = 0 - for transfer in transfers: - if transfer.amount_atomic <= 0: - continue - total_atomic += transfer.amount_atomic - if transfer.confirmations > max_confirmations: - max_confirmations = transfer.confirmations - logger.debug( - "Invoice totals", - extra={ - "invoice_id": str(invoice.id), - "received_atomic": total_atomic, - "confirmations": max_confirmations, - }, - ) - now = datetime.now(timezone.utc) - previous_confirmations = invoice.confirmations or 0 - total_changed = invoice.total_paid_atomic != total_atomic - confirmations_changed = previous_confirmations != max_confirmations - transfers_changed = _sync_invoice_transfers( - db, - invoice=invoice, - transfers=transfers, - ) - if total_changed or confirmations_changed or transfers_changed: - if confirmations_changed: - invoice.confirmations = max_confirmations - if total_changed: - invoice.total_paid_atomic = total_atomic - db.add(invoice) - db.commit() - required_atomic = _xmr_to_atomic(invoice.amount_xmr) - is_paid = total_atomic >= required_atomic - expires_at = invoice.expires_at - if expires_at is not None and expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - is_after_expiry = bool(expires_at and now >= expires_at) - - if total_atomic < required_atomic: + total_atomic = 0 + max_confirmations = 0 + for transfer in transfers: + if transfer.amount_atomic <= 0: + continue + total_atomic += transfer.amount_atomic + if transfer.confirmations > max_confirmations: + max_confirmations = transfer.confirmations logger.debug( - "Payment not yet detected", + "Invoice totals", extra={ "invoice_id": str(invoice.id), - "required_atomic": required_atomic, "received_atomic": total_atomic, + "confirmations": max_confirmations, }, ) - if invoice.status == "pending" and is_after_expiry: - logger.info( - "Invoice expired", - extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, - ) - invoice.status = "expired" + now = datetime.now(timezone.utc) + previous_confirmations = invoice.confirmations or 0 + total_changed = invoice.total_paid_atomic != total_atomic + confirmations_changed = previous_confirmations != max_confirmations + transfers_changed = _sync_invoice_transfers( + db, + invoice=invoice, + transfers=transfers, + ) + if total_changed or confirmations_changed or transfers_changed: + if confirmations_changed: + invoice.confirmations = max_confirmations + if total_changed: + invoice.total_paid_atomic = total_atomic db.add(invoice) db.commit() - dispatch_webhooks(db, str(user.id), "invoice.expired", invoice) - dispatch_btcpay_webhooks( - db, str(user.id), "InvoiceExpired", invoice - ) - continue + required_atomic = _xmr_to_atomic(invoice.amount_xmr) + is_paid = total_atomic >= required_atomic - if is_paid and invoice.status in ("pending", "expired"): - logger.info( - "Invoice marked payment detected", + expires_at = invoice.expires_at + if expires_at is not None and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + is_after_expiry = bool(expires_at and now >= expires_at) + + if total_atomic < required_atomic: + logger.debug( + "Payment not yet detected", + extra={ + "invoice_id": str(invoice.id), + "required_atomic": required_atomic, + "received_atomic": total_atomic, + }, + ) + if invoice.status == "pending" and is_after_expiry: + logger.info( + "Invoice expired", + extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, + ) + invoice.status = "expired" + db.add(invoice) + db.commit() + dispatch_webhooks(db, str(user.id), "invoice.expired", invoice) + dispatch_btcpay_webhooks( + db, str(user.id), "InvoiceExpired", invoice + ) + continue + + if is_paid and invoice.status in ("pending", "expired"): + logger.info( + "Invoice marked payment detected", + extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, + ) + previous_status = invoice.status + invoice.status = "payment_detected" + if invoice.detected_at is None: + invoice.detected_at = now + if previous_status == "expired" or (previous_status == "pending" and is_after_expiry): + invoice.paid_after_expiry = True + if invoice.paid_after_expiry_at is None: + invoice.paid_after_expiry_at = now + db.add(invoice) + db.commit() + dispatch_webhooks(db, str(user.id), "invoice.payment_detected", invoice) + dispatch_btcpay_webhooks( + db, str(user.id), "InvoiceReceivedPayment", invoice + ) + dispatch_btcpay_webhooks(db, str(user.id), "InvoicePaidInFull", invoice) + dispatch_btcpay_webhooks( + db, str(user.id), "InvoiceProcessing", invoice + ) + if max_confirmations >= invoice.confirmation_target and invoice.status != "confirmed": + logger.info( + "Invoice confirmed", + extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, + ) + invoice.status = "confirmed" + if invoice.confirmed_at is None: + invoice.confirmed_at = now + db.add(invoice) + db.commit() + dispatch_webhooks(db, str(user.id), "invoice.confirmed", invoice) + dispatch_btcpay_webhooks(db, str(user.id), "InvoiceSettled", invoice) + dispatch_btcpay_webhooks( + db, str(user.id), "InvoicePaymentSettled", invoice + ) + except Exception as exc: + logger.error( + "Failed to reconcile invoice: %s", + exc, extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, ) - previous_status = invoice.status - invoice.status = "payment_detected" - if invoice.detected_at is None: - invoice.detected_at = now - if previous_status == "expired" or (previous_status == "pending" and is_after_expiry): - invoice.paid_after_expiry = True - if invoice.paid_after_expiry_at is None: - invoice.paid_after_expiry_at = now - db.add(invoice) - db.commit() - dispatch_webhooks(db, str(user.id), "invoice.payment_detected", invoice) - dispatch_btcpay_webhooks( - db, str(user.id), "InvoiceReceivedPayment", invoice - ) - dispatch_btcpay_webhooks(db, str(user.id), "InvoicePaidInFull", invoice) - dispatch_btcpay_webhooks( - db, str(user.id), "InvoiceProcessing", invoice - ) - if max_confirmations >= invoice.confirmation_target and invoice.status != "confirmed": - logger.info( - "Invoice confirmed", - extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, - ) - invoice.status = "confirmed" - if invoice.confirmed_at is None: - invoice.confirmed_at = now - db.add(invoice) - db.commit() - dispatch_webhooks(db, str(user.id), "invoice.confirmed", invoice) - dispatch_btcpay_webhooks(db, str(user.id), "InvoiceSettled", invoice) - dispatch_btcpay_webhooks( - db, str(user.id), "InvoicePaymentSettled", invoice - ) - db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id}) + try: + db.rollback() + except Exception: + pass + finally: + db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id}) finally: db.close() @@ -273,10 +277,12 @@ def _sync_invoice_transfers( stored.timestamp = transfer.timestamp stored.address = transfer.address changed = True - for stored in existing: - if stored.txid not in seen_txids: - db.delete(stored) - changed = True + # Only delete transfers if we got data back — don't wipe records on RPC glitch + if transfers: + for stored in existing: + if stored.txid not in seen_txids: + db.delete(stored) + changed = True return changed diff --git a/api/app/routes.py b/api/app/routes.py index 2b8a31d..5aee080 100644 --- a/api/app/routes.py +++ b/api/app/routes.py @@ -1,7 +1,7 @@ import uuid from typing import Any from datetime import datetime, timedelta, timezone -from decimal import Decimal, ROUND_DOWN, InvalidOperation +from decimal import Decimal, ROUND_DOWN, ROUND_UP, InvalidOperation import csv import io import json @@ -343,8 +343,12 @@ def _resolve_invoice_amount( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Fiat quote service unavailable", ) from exc + # Round UP to 5 decimal places so we never undercharge. + # 5 decimals = 0.00001 XMR ≈ $0.002 at $200/XMR — more than enough precision. + # See also: btcpay_routes.py for the same rounding. + # To change precision: adjust the Decimal quantize string here and in btcpay_routes.py. amount_xmr = (Decimal(requested_amount_fiat) / quote.rate).quantize( - Decimal("0.000000000001"), rounding=ROUND_DOWN + Decimal("0.00001"), rounding=ROUND_UP ) warnings = ["Fiat conversion is an estimate and does not lock a rate."] quote_payload = {