From 3465c2054bf7e9cdb43f9ac27c9a113395f8fc34 Mon Sep 17 00:00:00 2001 From: xmrcheckout Date: Sun, 4 Jan 2026 15:41:46 +0100 Subject: [PATCH] Add api --- api/.env.example | 6 + api/Dockerfile | 12 + api/app/__init__.py | 1 + api/app/btcpay_routes.py | 701 +++++++++++++++++ api/app/btcpay_schemas.py | 65 ++ api/app/btcpay_webhooks.py | 111 +++ api/app/config.py | 47 ++ api/app/db.py | 16 + api/app/formatting.py | 9 + api/app/main.py | 242 ++++++ api/app/models.py | 145 ++++ api/app/monero_service.py | 453 +++++++++++ api/app/qr_codes.py | 176 +++++ api/app/rates.py | 68 ++ api/app/reconciler.py | 264 +++++++ api/app/routes.py | 1214 ++++++++++++++++++++++++++++++ api/app/schemas.py | 243 ++++++ api/app/security.py | 89 +++ api/app/subaddress_allocator.py | 48 ++ api/app/subaddress_derivation.py | 34 + api/app/webhooks.py | 122 +++ api/requirements.txt | 13 + 22 files changed, 4079 insertions(+) create mode 100644 api/.env.example create mode 100644 api/Dockerfile create mode 100644 api/app/__init__.py create mode 100644 api/app/btcpay_routes.py create mode 100644 api/app/btcpay_schemas.py create mode 100644 api/app/btcpay_webhooks.py create mode 100644 api/app/config.py create mode 100644 api/app/db.py create mode 100644 api/app/formatting.py create mode 100644 api/app/main.py create mode 100644 api/app/models.py create mode 100644 api/app/monero_service.py create mode 100644 api/app/qr_codes.py create mode 100644 api/app/rates.py create mode 100644 api/app/reconciler.py create mode 100644 api/app/routes.py create mode 100644 api/app/schemas.py create mode 100644 api/app/security.py create mode 100644 api/app/subaddress_allocator.py create mode 100644 api/app/subaddress_derivation.py create mode 100644 api/app/webhooks.py create mode 100644 api/requirements.txt diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..6caee57 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL=postgresql://xmrcheckout:xmrcheckout@localhost:5432/xmrcheckout +API_KEYS=change-me-1,change-me-2 +COINGECKO_API_KEY= +FOUNDER_PAYMENT_ADDRESS= +FOUNDER_VIEW_KEY= +DONATIONS_ENABLED=false diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..2ff277f --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY app /app/app + +EXPOSE 8000 + +CMD ["/bin/sh", "-c", "gunicorn app.main:app -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-2}"] diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/api/app/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/api/app/btcpay_routes.py b/api/app/btcpay_routes.py new file mode 100644 index 0000000..8156bf2 --- /dev/null +++ b/api/app/btcpay_routes.py @@ -0,0 +1,701 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_DOWN +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from .btcpay_schemas import ( + BtcpayInvoiceCreate, + BtcpayInvoiceStatusUpdate, + BtcpayWebhookCreate, + BtcpayWebhookUpdate, +) +from .btcpay_webhooks import dispatch_btcpay_webhooks +from .config import INVOICE_DEFAULT_EXPIRY_HOURS +from .config import QR_STORAGE_DIR +from .db import get_db +from .formatting import format_xmr_amount +from .models import BtcpayWebhook, Invoice, InvoiceTransfer, User +from .rates import get_xmr_rate +from .security import ( + decrypt_api_key, + encrypt_secret, + generate_webhook_secret, + hash_api_key, + require_api_key, +) +from .subaddress_allocator import create_subaddress_for_user +from .webhooks import dispatch_webhooks +from .qr_codes import ensure_invoice_qr_png, resolve_qr_settings + +router = APIRouter() + +BTCPAY_PAYMENT_METHOD = "XMR-CHAIN" +BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "XMR", "XMR_CHAIN"} +BTCPAY_WEBHOOK_EVENTS = { + "InvoiceReceivedPayment", + "InvoicePaymentSettled", + "InvoiceProcessing", + "InvoiceExpired", + "InvoiceSettled", + "InvoiceInvalid", +} + + +def _require_btcpay_user( + api_key: str = Depends(require_api_key), + db: Session = Depends(get_db), +) -> User: + user = db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + return user + + +def _require_store(store_id: str, user: User) -> str: + if store_id != str(user.id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Store not found", + ) + return store_id + + +def _store_payload(user: User) -> dict[str, Any]: + expiry_minutes = max(1, INVOICE_DEFAULT_EXPIRY_HOURS * 60) + return { + "id": str(user.id), + "name": "XMR Checkout", + "website": "", + "defaultCurrency": "XMR", + "invoiceExpiration": expiry_minutes, + "displayExpirationTimer": True, + "monitoringExpiration": expiry_minutes, + "speedPolicy": "MediumSpeed", + "paymentTolerance": 0, + "anyoneCanCreateInvoice": False, + "requiresRefundEmail": False, + "lightningAmountInSatoshi": False, + "lightningPrivateRouteHints": False, + "onChainWithLnInvoiceFallback": False, + "redirectAutomatically": False, + "showRecommendedFee": False, + "recommendedFeeBlockTarget": 0, + "defaultLang": "en", + "customLogo": "", + "customCSS": "", + "htmlTitle": "XMR Checkout", + "networkFeeMode": "Never", + "payJoinEnabled": False, + "lazyPaymentMethods": False, + "defaultPaymentMethod": BTCPAY_PAYMENT_METHOD, + } + + +def _invoice_checkout_link(invoice: Invoice, request: Request) -> str: + proto = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", "")) + if not host: + return f"/i/{invoice.id}" + return f"{proto}://{host}/i/{invoice.id}" + + +def _normalize_btcpay_payment_method(value: str) -> str: + normalized = value.strip().upper() + if normalized in BTCPAY_PAYMENT_METHOD_ALIASES: + return BTCPAY_PAYMENT_METHOD + return normalized + + +def _epoch_seconds(value: datetime | None) -> int: + if value is None: + return 0 + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return int(value.timestamp()) + + +def _resolve_btcpay_amount( + amount: Decimal, + currency: str, +) -> tuple[Decimal, dict[str, str] | None]: + if currency.upper() == "XMR": + return amount, None + try: + quote = get_xmr_rate(currency) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Fiat quote service unavailable", + ) from exc + amount_xmr = (Decimal(amount) / quote.rate).quantize( + Decimal("0.000000000001"), rounding=ROUND_DOWN + ) + quote_payload = { + "fiat_amount": str(amount), + "fiat_currency": quote.currency, + "rate": str(quote.rate), + "source": quote.source, + "quoted_at": quote.quoted_at.isoformat(), + } + return amount_xmr, quote_payload + + +def _btcpay_status(invoice: Invoice) -> tuple[str, str]: + additional_status = _btcpay_additional_status(invoice) + if invoice.status == "payment_detected": + return "Processing", additional_status + if invoice.status == "confirmed": + return "Settled", additional_status + if invoice.status == "expired": + return "Expired", additional_status + if invoice.status == "invalid": + return "Invalid", additional_status + return "New", additional_status + + +def _btcpay_additional_status(invoice: Invoice) -> str: + if invoice.status == "invalid": + return "Marked" + paid_atomic = invoice.total_paid_atomic or 0 + required_atomic = _xmr_to_atomic(invoice.amount_xmr) + if paid_atomic > required_atomic: + return "PaidOver" + if 0 < paid_atomic < required_atomic: + return "PaidPartial" + if _after_expiration(invoice): + return "PaidLate" + return "None" + + +def _after_expiration(invoice: Invoice) -> bool: + expires_at = invoice.expires_at + detected_at = invoice.detected_at + if expires_at is None or detected_at is None: + return False + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if detected_at.tzinfo is None: + detected_at = detected_at.replace(tzinfo=timezone.utc) + return detected_at > expires_at + + +def _xmr_to_atomic(amount: Decimal) -> int: + quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value( + rounding=ROUND_DOWN + ) + return int(quantized) + + +def _btcpay_amount_currency(invoice: Invoice) -> tuple[str, str, dict[str, Any]]: + metadata = invoice.metadata_json or {} + btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None + if isinstance(btcpay_data, dict): + amount = btcpay_data.get("amount") + currency = btcpay_data.get("currency") + if isinstance(amount, str) and isinstance(currency, str): + return amount, currency, btcpay_data + return format_xmr_amount(invoice.amount_xmr), "XMR", {} + + +def _format_xmr_fixed(value: Decimal) -> str: + return f"{value.quantize(Decimal('0.000000000001')):.12f}" + + +def _atomic_to_xmr(value: int) -> Decimal: + return (Decimal(value) / Decimal("1000000000000")).quantize( + Decimal("0.000000000001"), rounding=ROUND_DOWN + ) + + +@router.get("/api/v1/stores") +def list_stores(user: User = Depends(_require_btcpay_user)): + return [_store_payload(user)] + + +@router.get("/api/v1/stores/{store_id}") +def get_store(store_id: str, user: User = Depends(_require_btcpay_user)): + _require_store(store_id, user) + return _store_payload(user) + + +@router.get("/api/v1/stores/{store_id}/payment-methods") +def list_payment_methods(store_id: str, user: User = Depends(_require_btcpay_user)): + _require_store(store_id, user) + return [ + { + "paymentMethodId": BTCPAY_PAYMENT_METHOD, + "paymentMethod": BTCPAY_PAYMENT_METHOD, + "enabled": True, + "cryptoCode": "XMR", + } + ] + + +@router.post("/api/v1/stores/{store_id}/invoices", status_code=200) +def create_invoice( + store_id: str, + payload: BtcpayInvoiceCreate, + request: Request, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + checkout = payload.checkout + payment_methods = ( + [_normalize_btcpay_payment_method(method) for method in checkout.paymentMethods] + if checkout and checkout.paymentMethods + else None + ) + if payment_methods and BTCPAY_PAYMENT_METHOD not in payment_methods: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported payment method", + ) + amount_xmr, quote_payload = _resolve_btcpay_amount(payload.amount, payload.currency) + expires_at = None + now = datetime.now(timezone.utc) + expiration_minutes = None + monitoring_minutes = None + if checkout and checkout.expirationMinutes: + expiration_minutes = int(checkout.expirationMinutes) + if expiration_minutes: + expires_at = now + timedelta(minutes=expiration_minutes) + else: + expires_at = now + timedelta(hours=INVOICE_DEFAULT_EXPIRY_HOURS) + if checkout and checkout.monitoringMinutes is not None: + monitoring_minutes = int(checkout.monitoringMinutes) + metadata: dict[str, Any] = dict(payload.metadata or {}) + btcpay_data = metadata.get("btcpay") if isinstance(metadata.get("btcpay"), dict) else {} + btcpay_data = dict(btcpay_data) + btcpay_data.update( + { + "amount": str(payload.amount), + "currency": payload.currency, + "checkout": checkout.model_dump(mode="json") if checkout else None, + "expiration_minutes": expiration_minutes, + "monitoring_minutes": monitoring_minutes, + } + ) + metadata["btcpay"] = btcpay_data + if quote_payload and "quote" not in metadata: + metadata["quote"] = quote_payload + qr = metadata.get("qr") + if not isinstance(qr, dict): + qr = {"logo": user.default_qr_logo} + if user.default_qr_logo == "custom" and user.default_qr_logo_data_url: + qr["logo_data_url"] = user.default_qr_logo_data_url + metadata["qr"] = qr + address, subaddress_index = create_subaddress_for_user(db, user=user) + invoice = Invoice( + user_id=user.id, + wallet_address=user.payment_address, + address=address, + subaddress_index=subaddress_index, + amount_xmr=amount_xmr, + status="pending", + confirmation_target=user.default_confirmation_target, + total_paid_atomic=0, + metadata_json=metadata, + expires_at=expires_at, + ) + db.add(invoice) + db.commit() + db.refresh(invoice) + try: + ensure_invoice_qr_png( + invoice=invoice, + storage_dir=QR_STORAGE_DIR, + settings=resolve_qr_settings(invoice), + ) + except HTTPException: + pass + dispatch_webhooks(db, str(user.id), "invoice.created", invoice) + status_name, additional_status = _btcpay_status(invoice) + amount, currency, _ = _btcpay_amount_currency(invoice) + expiration_time = _epoch_seconds(invoice.expires_at) + monitoring_time = expiration_time + if monitoring_minutes is None: + monitoring_minutes = 60 + monitoring_time = expiration_time + int(monitoring_minutes * 60) + return { + "id": str(invoice.id), + "amount": amount, + "currency": currency, + "type": "Standard", + "checkoutLink": _invoice_checkout_link(invoice, request), + "createdTime": _epoch_seconds(invoice.created_at), + "expirationTime": expiration_time, + "monitoringTime": monitoring_time, + "archived": False, + "status": status_name, + "additionalStatus": additional_status, + "availableStatusesForManualMarking": ["Invalid"], + } + + +@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}") +def get_invoice( + store_id: str, + invoice_id: str, + request: Request, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + try: + invoice_uuid = uuid.UUID(invoice_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + invoice = ( + db.query(Invoice) + .filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id) + .first() + ) + if invoice is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + status_name, additional_status = _btcpay_status(invoice) + amount, currency, btcpay_data = _btcpay_amount_currency(invoice) + expiration_time = _epoch_seconds(invoice.expires_at) + monitoring_minutes = ( + btcpay_data.get("monitoring_minutes") + if isinstance(btcpay_data, dict) + else None + ) + if monitoring_minutes is None: + monitoring_minutes = 60 + monitoring_time = expiration_time + int(int(monitoring_minutes) * 60) + return { + "id": str(invoice.id), + "amount": amount, + "currency": currency, + "type": "Standard", + "checkoutLink": _invoice_checkout_link(invoice, request), + "createdTime": _epoch_seconds(invoice.created_at), + "expirationTime": expiration_time, + "monitoringTime": monitoring_time, + "archived": invoice.archived_at is not None, + "status": status_name, + "additionalStatus": additional_status, + "availableStatusesForManualMarking": ["Invalid"], + } + + +@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods") +def get_invoice_payment_methods( + store_id: str, + invoice_id: str, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + try: + invoice_uuid = uuid.UUID(invoice_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + invoice = ( + db.query(Invoice) + .filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id) + .first() + ) + if invoice is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + transfers = ( + db.query(InvoiceTransfer) + .filter(InvoiceTransfer.invoice_id == invoice.id) + .order_by(InvoiceTransfer.created_at.asc()) + .all() + ) + total_atomic = sum( + transfer.amount_atomic for transfer in transfers if transfer.amount_atomic > 0 + ) + total_paid = _atomic_to_xmr(total_atomic) + due = max(Decimal("0"), invoice.amount_xmr - total_paid) + metadata = invoice.metadata_json or {} + quote = metadata.get("quote") if isinstance(metadata, dict) else None + rate_value = "0" + if isinstance(quote, dict) and "rate" in quote: + rate_value = str(quote.get("rate", "0")) + payments = [] + created_fallback = _epoch_seconds(invoice.created_at) + for transfer in transfers: + if transfer.amount_atomic <= 0: + continue + confirmations = transfer.confirmations + status_label = ( + "confirmed" + if confirmations >= max(0, invoice.confirmation_target) + else "pending" + ) + payments.append( + { + "id": f"{transfer.txid}-0", + "value": _format_xmr_fixed(_atomic_to_xmr(transfer.amount_atomic)), + "fee": "0", + "destination": transfer.address or invoice.address, + "status": status_label, + "receivedDate": transfer.timestamp or created_fallback, + } + ) + return [ + { + "paymentMethodId": BTCPAY_PAYMENT_METHOD, + "paymentMethod": BTCPAY_PAYMENT_METHOD, + "cryptoCode": "XMR", + "destination": invoice.address, + "rate": rate_value, + "amount": _format_xmr_fixed(invoice.amount_xmr), + "due": _format_xmr_fixed(due), + "totalPaid": _format_xmr_fixed(total_paid), + "paymentMethodPaid": _format_xmr_fixed(total_paid), + "networkFee": "0", + "payments": payments, + } + ] + + +@router.post("/api/v1/stores/{store_id}/invoices/{invoice_id}/status", status_code=200) +def mark_invoice_status( + store_id: str, + invoice_id: str, + payload: BtcpayInvoiceStatusUpdate, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + if payload.status.strip().lower() != "invalid": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported status", + ) + try: + invoice_uuid = uuid.UUID(invoice_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + invoice = ( + db.query(Invoice) + .filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id) + .first() + ) + if invoice is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + if invoice.status == "confirmed": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Confirmed invoices cannot be marked invalid", + ) + invoice.status = "invalid" + db.add(invoice) + db.commit() + db.refresh(invoice) + dispatch_btcpay_webhooks( + db, + str(user.id), + "InvoiceInvalid", + invoice, + manually_marked=True, + ) + return {"status": "Invalid"} + + +@router.post("/api/v1/stores/{store_id}/webhooks", status_code=200) +def create_webhook( + store_id: str, + payload: BtcpayWebhookCreate, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + _validate_webhook_events(payload.authorizedEvents.specificEvents) + secret = generate_webhook_secret() + webhook = BtcpayWebhook( + user_id=user.id, + url=str(payload.url), + enabled=payload.enabled, + automatic_redelivery=payload.automaticRedelivery, + authorized_events=payload.authorizedEvents.model_dump(), + secret_encrypted=encrypt_secret(secret), + ) + db.add(webhook) + db.commit() + db.refresh(webhook) + return _webhook_response(webhook, include_secret=True, secret=secret) + + +@router.get("/api/v1/stores/{store_id}/webhooks") +def list_webhooks( + store_id: str, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + hooks = ( + db.query(BtcpayWebhook) + .filter(BtcpayWebhook.user_id == user.id) + .order_by(BtcpayWebhook.created_at.desc()) + .all() + ) + return [_webhook_response(hook) for hook in hooks] + + +@router.get("/api/v1/stores/{store_id}/webhooks/{webhook_id}") +def get_webhook( + store_id: str, + webhook_id: str, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + hook = _get_webhook(db, webhook_id, user) + return _webhook_response(hook) + + +@router.put("/api/v1/stores/{store_id}/webhooks/{webhook_id}") +def update_webhook( + store_id: str, + webhook_id: str, + payload: BtcpayWebhookUpdate, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + hook = _get_webhook(db, webhook_id, user) + if payload.authorizedEvents and payload.authorizedEvents.specificEvents: + _validate_webhook_events(payload.authorizedEvents.specificEvents) + if payload.enabled is not None: + hook.enabled = payload.enabled + if payload.automaticRedelivery is not None: + hook.automatic_redelivery = payload.automaticRedelivery + if payload.url is not None: + hook.url = str(payload.url) + if payload.authorizedEvents is not None: + hook.authorized_events = payload.authorizedEvents.model_dump() + db.add(hook) + db.commit() + db.refresh(hook) + return _webhook_response(hook) + + +@router.delete( + "/api/v1/stores/{store_id}/webhooks/{webhook_id}", + status_code=204, +) +def delete_webhook( + store_id: str, + webhook_id: str, + user: User = Depends(_require_btcpay_user), + db: Session = Depends(get_db), +): + _require_store(store_id, user) + hook = _get_webhook(db, webhook_id, user) + db.delete(hook) + db.commit() + return None + + +@router.get("/api/v1/server/info") +def server_info(): + return { + "version": "1.7.5", + "onion": None, + "fullySynched": True, + "supportedPaymentMethods": [BTCPAY_PAYMENT_METHOD], + } + + +@router.get("/api/v1/api-keys/current") +def api_key_current( + user: User = Depends(_require_btcpay_user), +): + store_id = str(user.id) + return { + "apiKey": decrypt_api_key(user.api_key_encrypted), + "label": "XMR Checkout", + "permissions": [ + f"btcpay.store.canviewinvoices:{store_id}", + f"btcpay.store.cancreateinvoice:{store_id}", + f"btcpay.store.canviewstoresettings:{store_id}", + f"btcpay.store.canmodifyinvoices:{store_id}", + f"btcpay.store.webhooks.canmodifywebhooks:{store_id}", + ], + } + + +def _get_webhook(db: Session, webhook_id: str, user: User) -> BtcpayWebhook: + try: + webhook_uuid = uuid.UUID(webhook_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Webhook not found", + ) + hook = ( + db.query(BtcpayWebhook) + .filter(BtcpayWebhook.id == webhook_uuid, BtcpayWebhook.user_id == user.id) + .first() + ) + if hook is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Webhook not found", + ) + return hook + + +def _validate_webhook_events(events: list[str] | None) -> None: + if not events: + return + for event in events: + if event not in BTCPAY_WEBHOOK_EVENTS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Webhook event is not supported", + ) + + +def _webhook_response( + hook: BtcpayWebhook, + *, + include_secret: bool = False, + secret: str | None = None, +) -> dict[str, Any]: + payload = { + "id": str(hook.id), + "enabled": hook.enabled, + "automaticRedelivery": hook.automatic_redelivery, + "url": hook.url, + "authorizedEvents": hook.authorized_events, + } + if include_secret and secret: + payload["secret"] = secret + return payload diff --git a/api/app/btcpay_schemas.py b/api/app/btcpay_schemas.py new file mode 100644 index 0000000..24e9333 --- /dev/null +++ b/api/app/btcpay_schemas.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from pydantic import ( + AnyUrl, + BaseModel, + Field, + conint, + field_validator, + model_validator, +) + + +class BtcpayCheckout(BaseModel): + redirectURL: AnyUrl | None = None + speedPolicy: str | None = None + paymentMethods: list[str] | None = None + expirationMinutes: conint(ge=1, le=10080) | None = None + monitoringMinutes: conint(ge=0, le=10080) | None = None + paymentTolerance: Decimal | None = None + redirectAutomatically: bool | None = None + defaultLanguage: str | None = None + + +class BtcpayInvoiceCreate(BaseModel): + amount: Decimal = Field(gt=0) + currency: str = Field(min_length=1) + metadata: dict[str, Any] | None = None + checkout: BtcpayCheckout | None = None + + @field_validator("currency") + @classmethod + def _normalize_currency(cls, value: str) -> str: + return value.strip().upper() + + +class BtcpayWebhookEvents(BaseModel): + everything: bool = False + specificEvents: list[str] | None = None + + @model_validator(mode="after") + def _validate_selection(self) -> "BtcpayWebhookEvents": + if not self.everything and not self.specificEvents: + raise ValueError("Select at least one event") + return self + + +class BtcpayWebhookCreate(BaseModel): + enabled: bool = True + automaticRedelivery: bool = True + url: AnyUrl + authorizedEvents: BtcpayWebhookEvents + + +class BtcpayWebhookUpdate(BaseModel): + enabled: bool | None = None + automaticRedelivery: bool | None = None + url: AnyUrl | None = None + authorizedEvents: BtcpayWebhookEvents | None = None + + +class BtcpayInvoiceStatusUpdate(BaseModel): + status: str = Field(min_length=1) diff --git a/api/app/btcpay_webhooks.py b/api/app/btcpay_webhooks.py new file mode 100644 index 0000000..62180a1 --- /dev/null +++ b/api/app/btcpay_webhooks.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import time +from datetime import datetime, timezone + +import requests +from requests import RequestException +from sqlalchemy.orm import Session + +from .models import BtcpayWebhook, Invoice +from .security import decrypt_secret + +logger = logging.getLogger(__name__) + + +def dispatch_btcpay_webhooks( + db: Session, + user_id: str, + event_type: str, + invoice: Invoice, + *, + manually_marked: bool = False, +) -> None: + hooks = ( + db.query(BtcpayWebhook) + .filter( + BtcpayWebhook.user_id == user_id, + BtcpayWebhook.enabled.is_(True), + ) + .all() + ) + if not hooks: + return + payload = _build_payload( + event_type=event_type, + user_id=user_id, + invoice=invoice, + manually_marked=manually_marked, + ) + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + for hook in hooks: + if not _event_allowed(hook.authorized_events, event_type): + continue + try: + secret = decrypt_secret(hook.secret_encrypted) + signature = _sign_payload(body, secret) + headers = { + "BTCPay-Sig": f"sha256={signature}", + "Content-Type": "application/json", + } + requests.post(hook.url, data=body, headers=headers, timeout=5) + except RequestException as exc: + logger.warning( + "BTCPay webhook delivery failed", + extra={"webhook_id": str(hook.id), "event": event_type}, + ) + 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)}, + ) + + +def _event_allowed(authorized_events: object, event_type: str) -> bool: + if not isinstance(authorized_events, dict): + return False + if authorized_events.get("everything") is True: + return True + specific = authorized_events.get("specificEvents") or [] + return event_type in specific + + +def _build_payload( + *, + event_type: str, + user_id: str, + invoice: Invoice, + manually_marked: bool, +) -> dict[str, object]: + return { + "type": event_type, + "timestamp": int(time.time()), + "storeId": user_id, + "invoiceId": str(invoice.id), + "manuallyMarked": manually_marked, + "overPaid": False, + "partiallyPaid": False, + "afterExpiration": _after_expiration(invoice), + "metadata": invoice.metadata_json or {}, + } + + +def _after_expiration(invoice: Invoice) -> bool: + expires_at = invoice.expires_at + detected_at = invoice.detected_at + if expires_at is None or detected_at is None: + return False + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if detected_at.tzinfo is None: + detected_at = detected_at.replace(tzinfo=timezone.utc) + return detected_at > expires_at + + +def _sign_payload(body: bytes, secret: str) -> str: + return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..2aa8f6c --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,47 @@ +import os + + +def _get_bool_env(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None or value == "": + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _get_env(name: str, default: str | None = None) -> str: + value = os.getenv(name, default) + if value is None or value == "": + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +DATABASE_URL = _get_env("DATABASE_URL") + +API_KEYS = [ + key.strip() + for key in os.getenv("API_KEYS", "").split(",") + if key.strip() +] + +API_KEY_ENCRYPTION_KEY = _get_env("API_KEY_ENCRYPTION_KEY") +INVOICE_RECONCILE_INTERVAL_SECONDS = int(os.getenv("INVOICE_RECONCILE_INTERVAL_SECONDS", "30")) +INVOICE_DEFAULT_EXPIRY_HOURS = int(os.getenv("INVOICE_DEFAULT_EXPIRY_HOURS", "24")) +LATE_PAYMENT_LOOKBACK_HOURS = int(os.getenv("LATE_PAYMENT_LOOKBACK_HOURS", "48")) +DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30")) +DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25")) +COINGECKO_API_KEY = os.getenv("COINGECKO_API_KEY", "") +DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False) + +MONERO_WALLET_RPC_URLS = [ + url.strip() + for url in os.getenv("MONERO_WALLET_RPC_URLS", "").split(",") + if url.strip() +] +MONERO_WALLET_RPC_USER = os.getenv("MONERO_WALLET_RPC_USER", "") +MONERO_WALLET_RPC_PASSWORD = os.getenv("MONERO_WALLET_RPC_PASSWORD", "") +MONERO_WALLET_RPC_WALLET_PASSWORD = os.getenv("MONERO_WALLET_RPC_WALLET_PASSWORD", "") +MONERO_DAEMON_URL = os.getenv("MONERO_DAEMON_URL") +MONERO_WALLET_RPC_WALLET_DIR = os.getenv("MONERO_WALLET_RPC_WALLET_DIR", "") +FOUNDER_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "") +FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "") +QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr") diff --git a/api/app/db.py b/api/app/db.py new file mode 100644 index 0000000..1315f24 --- /dev/null +++ b/api/app/db.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from .config import DATABASE_URL + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/api/app/formatting.py b/api/app/formatting.py new file mode 100644 index 0000000..10b4836 --- /dev/null +++ b/api/app/formatting.py @@ -0,0 +1,9 @@ +from decimal import Decimal + + +def format_xmr_amount(value: Decimal) -> str: + normalized = value.normalize() + formatted = format(normalized, "f") + if "." in formatted: + formatted = formatted.rstrip("0").rstrip(".") + return formatted or "0" diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..d869ed4 --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,242 @@ +import logging +import os + +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from sqlalchemy import text + +from .db import Base, engine +from .config import DONATIONS_ENABLED +from .btcpay_routes import router as btcpay_router +from .routes import router + +app = FastAPI(title="xmrcheckout.com API", version="0.1.0") + +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper()) + + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + openapi_schema.get("paths", {}).pop("/api/core/donations", None) + if not DONATIONS_ENABLED: + openapi_schema.get("paths", {}).pop("/api/core/public/donation/{invoice_id}", None) + openapi_schema.get("paths", {}).pop("/api/core/auth/validate", None) + openapi_schema.get("paths", {}).pop("/api/core/profile", None) + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi + + +@app.on_event("startup") +def startup(): + lock_acquired = False + lock_id = 894221741 + with engine.begin() as connection: + try: + connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": lock_id}) + lock_acquired = True + except Exception: + lock_acquired = False + try: + Base.metadata.create_all(bind=connection) + connection.execute( + text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS user_id UUID") + ) + connection.execute( + text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS wallet_address VARCHAR") + ) + connection.execute( + text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS subaddress_index INTEGER") + ) + connection.execute( + text( + "ALTER TABLE invoices " + "ADD COLUMN IF NOT EXISTS confirmations INTEGER DEFAULT 0" + ) + ) + connection.execute( + text( + "ALTER TABLE invoices " + "ADD COLUMN IF NOT EXISTS total_paid_atomic BIGINT" + ) + ) + connection.execute( + text( + "ALTER TABLE invoices " + "ADD COLUMN IF NOT EXISTS paid_after_expiry BOOLEAN DEFAULT false" + ) + ) + connection.execute( + text( + "ALTER TABLE invoices " + "ADD COLUMN IF NOT EXISTS paid_after_expiry_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text( + "ALTER TABLE invoices " + "ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text("UPDATE invoices SET confirmations = 0 WHERE confirmations IS NULL") + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_invoices_user_id ON invoices (user_id)") + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_invoices_wallet_address ON invoices (wallet_address)" + ) + ) + connection.execute(text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS user_id UUID")) + connection.execute( + text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS event_urls JSON") + ) + connection.execute( + text("ALTER TABLE webhooks ALTER COLUMN url DROP NOT NULL") + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_webhooks_user_id ON webhooks (user_id)") + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_user_id " + "ON webhook_deliveries (user_id)" + ) + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_webhook_id " + "ON webhook_deliveries (webhook_id)" + ) + ) + connection.execute( + text( + "ALTER TABLE webhook_deliveries " + "ADD COLUMN IF NOT EXISTS invoice_id UUID" + ) + ) + connection.execute( + text( + "ALTER TABLE webhook_deliveries " + "ADD COLUMN IF NOT EXISTS invoice_address VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE webhook_deliveries " + "ADD COLUMN IF NOT EXISTS invoice_subaddress_index INTEGER" + ) + ) + connection.execute( + text( + "ALTER TABLE webhook_deliveries " + "ADD COLUMN IF NOT EXISTS invoice_amount_xmr NUMERIC(18, 12)" + ) + ) + connection.execute( + text( + "ALTER TABLE webhook_deliveries " + "ADD COLUMN IF NOT EXISTS invoice_status VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE webhook_deliveries " + "ADD COLUMN IF NOT EXISTS payload JSON" + ) + ) + connection.execute( + text("ALTER TABLE users ADD COLUMN IF NOT EXISTS payment_address VARCHAR") + ) + connection.execute( + text("ALTER TABLE users ADD COLUMN IF NOT EXISTS view_key_encrypted VARCHAR") + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS webhook_secret_encrypted VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS next_subaddress_index INTEGER DEFAULT 1" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS subaddress_start_index INTEGER DEFAULT 0" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS default_confirmation_target INTEGER DEFAULT 10" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'monero'" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS default_qr_logo_data_url VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'standard'" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()" + ) + ) + connection.execute( + text( + "UPDATE users SET btcpay_checkout_style = 'standard' " + "WHERE btcpay_checkout_style IS NULL" + ) + ) + connection.execute( + text( + "UPDATE users SET default_confirmation_target = 10 " + "WHERE default_confirmation_target IS NULL" + ) + ) + connection.execute( + text( + "UPDATE users SET default_qr_logo = 'monero' " + "WHERE default_qr_logo IS NULL" + ) + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_users_payment_address ON users (payment_address)") + ) + finally: + if lock_acquired: + connection.execute( + text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id} + ) + + +app.include_router(router) +app.include_router(btcpay_router) diff --git a/api/app/models.py b/api/app/models.py new file mode 100644 index 0000000..65750f3 --- /dev/null +++ b/api/app/models.py @@ -0,0 +1,145 @@ +import uuid + +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + DateTime, + Integer, + Numeric, + String, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import ARRAY, UUID +from sqlalchemy.sql import func +from sqlalchemy.types import JSON + +from .db import Base + + +class Invoice(Base): + __tablename__ = "invoices" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=True, index=True) + wallet_address = Column(String, nullable=True, index=True) + address = Column(String, nullable=False) + subaddress_index = Column(Integer, nullable=True) + amount_xmr = Column(Numeric(18, 12), nullable=False) + status = Column(String, nullable=False, index=True) + confirmation_target = Column(Integer, nullable=False) + confirmations = Column(Integer, nullable=False, server_default="0") + total_paid_atomic = Column(BigInteger, nullable=True) + paid_after_expiry = Column(Boolean, nullable=False, server_default="false") + paid_after_expiry_at = Column(DateTime(timezone=True), nullable=True) + metadata_json = Column("metadata", JSON, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + archived_at = Column(DateTime(timezone=True), nullable=True) + expires_at = Column(DateTime(timezone=True), nullable=True) + detected_at = Column(DateTime(timezone=True), nullable=True) + confirmed_at = Column(DateTime(timezone=True), nullable=True) + + +class InvoiceTransfer(Base): + __tablename__ = "invoice_transfers" + __table_args__ = ( + UniqueConstraint( + "invoice_id", + "txid", + name="uq_invoice_transfers_invoice_txid", + ), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + invoice_id = Column(UUID(as_uuid=True), nullable=False, index=True) + txid = Column(String, nullable=False) + amount_atomic = Column(BigInteger, nullable=False) + confirmations = Column(Integer, nullable=False, server_default="0") + timestamp = Column(BigInteger, nullable=True) + address = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + +class Webhook(Base): + __tablename__ = "webhooks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=True, index=True) + url = Column(String, nullable=True) + events = Column(ARRAY(String), nullable=False) + event_urls = Column(JSON, nullable=True) + active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class BtcpayWebhook(Base): + __tablename__ = "btcpay_webhooks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) + url = Column(String, nullable=False) + enabled = Column(Boolean, nullable=False, default=True) + automatic_redelivery = Column(Boolean, nullable=False, default=True) + authorized_events = Column(JSON, nullable=False) + secret_encrypted = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + +class WebhookDelivery(Base): + __tablename__ = "webhook_deliveries" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) + webhook_id = Column(UUID(as_uuid=True), nullable=True, index=True) + event = Column(String, nullable=False) + url = Column(String, nullable=False) + invoice_id = Column(UUID(as_uuid=True), nullable=True) + invoice_address = Column(String, nullable=True) + invoice_subaddress_index = Column(Integer, nullable=True) + invoice_amount_xmr = Column(Numeric(18, 12), nullable=True) + invoice_status = Column(String, nullable=True) + payload_json = Column("payload", JSON, nullable=True) + http_status = Column(Integer, nullable=True) + error_message = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class User(Base): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + payment_address = Column(String, nullable=False, unique=True, index=True) + view_key_encrypted = Column(String, nullable=False) + api_key_hash = Column(String, nullable=False, unique=True, index=True) + api_key_encrypted = Column(String, nullable=False) + webhook_secret_encrypted = Column(String, nullable=True) + next_subaddress_index = Column(Integer, nullable=False, server_default="1") + subaddress_start_index = Column(Integer, nullable=False, server_default="0") + default_confirmation_target = Column(Integer, nullable=False, server_default="10") + default_qr_logo = Column(String, nullable=False, server_default="monero") + default_qr_logo_data_url = Column(String, nullable=True) + btcpay_checkout_style = Column(String, nullable=False, server_default="standard") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ProfileHistory(Base): + __tablename__ = "profile_history" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) + field_name = Column(String, nullable=False) + old_value = Column(String, nullable=True) + new_value = Column(String, nullable=True) + value_encrypted = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/api/app/monero_service.py b/api/app/monero_service.py new file mode 100644 index 0000000..e4bb594 --- /dev/null +++ b/api/app/monero_service.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import logging +import time +from urllib.parse import urlparse + +from fastapi import HTTPException, status +from monero.backends.jsonrpc import JSONRPCWallet +from monero.backends.jsonrpc.exceptions import RPCError +import requests +from requests import RequestException +from requests.auth import HTTPDigestAuth + +from .config import ( + MONERO_DAEMON_URL, + MONERO_WALLET_RPC_PASSWORD, + MONERO_WALLET_RPC_URLS, + MONERO_WALLET_RPC_USER, + MONERO_WALLET_RPC_WALLET_PASSWORD, + MONERO_WALLET_RPC_WALLET_DIR, +) +from .models import User +from .security import decrypt_secret + +logger = logging.getLogger(__name__) + + +def _normalize_daemon_address(value: str | None) -> str | None: + if not value: + return None + parsed = urlparse(value) + if parsed.scheme: + address = parsed.netloc + else: + address = value.split("/", 1)[0] + return address.rstrip("/") if address else None + + +def _normalize_daemon_url(value: str | None) -> str | None: + if not value: + return None + parsed = urlparse(value) + if parsed.scheme: + return value.rstrip("/") + return f"http://{value.rstrip('/')}" + + +@dataclass(frozen=True) +class SubaddressResult: + address: str + address_index: int | None + + +@dataclass(frozen=True) +class TransferDetail: + txid: str + amount_atomic: int + confirmations: int + timestamp: int | None + address: str | None + + +@dataclass +class WalletBackend: + client: JSONRPCWallet + url: str + current_wallet: str | None = None + + +class MoneroWalletService: + def __init__(self) -> None: + urls = MONERO_WALLET_RPC_URLS + if not urls: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Monero wallet RPC is not configured", + ) + self._backends: list[WalletBackend] = [] + for url in urls: + parsed = urlparse(url) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 18082 + client = JSONRPCWallet( + host=host, + port=port, + user=MONERO_WALLET_RPC_USER or None, + password=MONERO_WALLET_RPC_PASSWORD or None, + ) + if MONERO_WALLET_RPC_USER and MONERO_WALLET_RPC_PASSWORD: + # monero-wallet-rpc uses digest auth when rpc-login is set. + client.session.auth = HTTPDigestAuth( + MONERO_WALLET_RPC_USER, MONERO_WALLET_RPC_PASSWORD + ) + self._backends.append(WalletBackend(client=client, url=url)) + self._daemon_url = _normalize_daemon_url(MONERO_DAEMON_URL) + self._daemon_address = _normalize_daemon_address(MONERO_DAEMON_URL) + self._wallet_dir = MONERO_WALLET_RPC_WALLET_DIR.strip() + + def get_status(self) -> dict[str, str]: + for backend in self._backends: + try: + backend.client.raw_request("get_version") + except Exception: + return {"wallet_rpc": "unreachable"} + + if self._daemon_address: + try: + self._backends[0].client.raw_request( + "set_daemon", + {"address": self._daemon_address}, + ) + except RPCError as exc: + message = str(exc) + if "no connection to daemon" in message or "no_connection_to_daemon" in message: + return {"wallet_rpc": "ok", "daemon": "unreachable"} + return {"wallet_rpc": "ok", "daemon": "ok" if self._daemon_address else "unknown"} + + def create_subaddress(self, user: User, label: str) -> SubaddressResult: + start_total = time.monotonic() + view_key = decrypt_secret(user.view_key_encrypted) + wallet_name = self._wallet_name(user, user.payment_address, view_key) + backend = self._backend_for_wallet_name(wallet_name) + start_wallet = time.monotonic() + self._ensure_wallet_open( + backend=backend, + wallet_name=wallet_name, + payment_address=user.payment_address, + view_key=view_key, + ) + wallet_elapsed = time.monotonic() - start_wallet + start_daemon = time.monotonic() + self._ensure_daemon(backend) + daemon_elapsed = time.monotonic() - start_daemon + + start_create = time.monotonic() + try: + response = backend.client.raw_request( + "create_address", + {"account_index": 0, "label": label}, + ) + except (RPCError, RequestException) as exc: + self._raise_wallet_rpc_error(exc) + create_elapsed = time.monotonic() - start_create + address = response.get("address") + if not address: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Monero wallet RPC did not return a subaddress", + ) + start_store = time.monotonic() + try: + backend.client.raw_request("store") + except RPCError: + # Storing can fail if the wallet RPC is in an odd state; the + # subaddress is still valid, but we prefer to surface issues later. + pass + store_elapsed = time.monotonic() - start_store + total_elapsed = time.monotonic() - start_total + logger.info( + "create_subaddress timing wallet=%.3fs daemon=%.3fs create=%.3fs store=%.3fs total=%.3fs", + wallet_elapsed, + daemon_elapsed, + create_elapsed, + store_elapsed, + total_elapsed, + ) + return SubaddressResult( + address=address, + address_index=response.get("address_index"), + ) + + def get_received_atomic( + self, + user: User, + address: str, + ) -> tuple[int, int]: + view_key = decrypt_secret(user.view_key_encrypted) + wallet_name = self._wallet_name(user, user.payment_address, view_key) + backend = self._backend_for_wallet_name(wallet_name) + self._ensure_wallet_open( + backend=backend, + wallet_name=wallet_name, + payment_address=user.payment_address, + view_key=view_key, + ) + self._ensure_daemon(backend) + try: + index_response = backend.client.raw_request( + "get_address_index", + {"address": address}, + ) + except (RPCError, RequestException) as exc: + self._raise_wallet_rpc_error(exc) + index = index_response.get("index") if isinstance(index_response, dict) else None + major = index.get("major") if isinstance(index, dict) else None + minor = index.get("minor") if isinstance(index, dict) else None + if major is None or minor is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Monero wallet RPC did not return an address index", + ) + try: + transfers = backend.client.raw_request( + "get_transfers", + { + "in": True, + "pool": True, + "account_index": major, + "subaddr_indices": [minor], + }, + ) + except (RPCError, RequestException) as exc: + self._raise_wallet_rpc_error(exc) + incoming = transfers.get("in", []) if isinstance(transfers, dict) else [] + pool = transfers.get("pool", []) if isinstance(transfers, dict) else [] + total_atomic = 0 + max_confirmations = 0 + seen_txids: set[str] = set() + for item in [*incoming, *pool]: + if not isinstance(item, dict): + continue + txid = item.get("txid") + if isinstance(txid, str): + if txid in seen_txids: + continue + seen_txids.add(txid) + total_atomic += int(item.get("amount", 0) or 0) + confirmations = int(item.get("confirmations", 0) or 0) + if confirmations > max_confirmations: + max_confirmations = confirmations + return total_atomic, max_confirmations + + def get_transfers_for_address( + self, + user: User, + address: str, + ) -> list[TransferDetail]: + view_key = decrypt_secret(user.view_key_encrypted) + wallet_name = self._wallet_name(user, user.payment_address, view_key) + backend = self._backend_for_wallet_name(wallet_name) + self._ensure_wallet_open( + backend=backend, + wallet_name=wallet_name, + payment_address=user.payment_address, + view_key=view_key, + ) + self._ensure_daemon(backend) + try: + index_response = backend.client.raw_request( + "get_address_index", + {"address": address}, + ) + except (RPCError, RequestException) as exc: + self._raise_wallet_rpc_error(exc) + index = index_response.get("index") if isinstance(index_response, dict) else None + major = index.get("major") if isinstance(index, dict) else None + minor = index.get("minor") if isinstance(index, dict) else None + if major is None or minor is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Monero wallet RPC did not return an address index", + ) + try: + transfers = backend.client.raw_request( + "get_transfers", + { + "in": True, + "pool": True, + "account_index": major, + "subaddr_indices": [minor], + }, + ) + except (RPCError, RequestException) as exc: + self._raise_wallet_rpc_error(exc) + incoming = transfers.get("in", []) if isinstance(transfers, dict) else [] + pool = transfers.get("pool", []) if isinstance(transfers, dict) else [] + details: list[TransferDetail] = [] + seen_txids: set[str] = set() + for item in [*incoming, *pool]: + if not isinstance(item, dict): + continue + txid = item.get("txid") + if not isinstance(txid, str) or not txid: + continue + if txid in seen_txids: + continue + seen_txids.add(txid) + amount_atomic = int(item.get("amount", 0) or 0) + confirmations = int(item.get("confirmations", 0) or 0) + timestamp = item.get("timestamp") + timestamp_value = int(timestamp) if isinstance(timestamp, (int, float)) else None + details.append( + TransferDetail( + txid=txid, + amount_atomic=amount_atomic, + confirmations=confirmations, + timestamp=timestamp_value, + address=item.get("address") if isinstance(item.get("address"), str) else None, + ) + ) + return details + + @staticmethod + def _rpc_error_message(exc: RPCError) -> str: + message = str(exc) + error = getattr(exc, "error", None) + if isinstance(error, dict): + detail = error.get("message") + if detail: + message = f"{message} {detail}" + return message + + def _daemon_height(self) -> int | None: + if not self._daemon_url: + return None + url = self._daemon_url.rstrip("/") + payload = {"jsonrpc": "2.0", "id": "0", "method": "get_height"} + try: + response = requests.post(f"{url}/json_rpc", json=payload, timeout=5) + response.raise_for_status() + data = response.json() + result = data.get("result") if isinstance(data, dict) else None + height = result.get("height") if isinstance(result, dict) else None + if isinstance(height, int): + return height + except (requests.RequestException, ValueError): + pass + + try: + response = requests.get(f"{url}/get_height", timeout=5) + response.raise_for_status() + data = response.json() + except (requests.RequestException, ValueError): + return None + height = data.get("height") if isinstance(data, dict) else None + return height if isinstance(height, int) else None + + @staticmethod + def _wallet_name(user: User, payment_address: str, view_key: str) -> str: + fingerprint = hashlib.sha256( + f"{payment_address}:{view_key}".encode("utf-8") + ).hexdigest()[:12] + return f"user-{user.id}-{fingerprint}" + + def _backend_for_wallet_name(self, wallet_name: str) -> WalletBackend: + digest = hashlib.sha256(wallet_name.encode("utf-8")).hexdigest() + index = int(digest, 16) % len(self._backends) + return self._backends[index] + + def _ensure_wallet_open( + self, + *, + backend: WalletBackend, + wallet_name: str, + payment_address: str, + view_key: str, + ) -> None: + if backend.current_wallet == wallet_name: + return + close_start = time.monotonic() + try: + backend.client.raw_request("close_wallet") + except (RPCError, RequestException): + pass + close_elapsed = time.monotonic() - close_start + open_start = time.monotonic() + try: + backend.client.raw_request( + "open_wallet", + {"filename": wallet_name, "password": MONERO_WALLET_RPC_WALLET_PASSWORD}, + ) + backend.current_wallet = wallet_name + open_elapsed = time.monotonic() - open_start + logger.info( + "wallet open timing close=%.3fs open=%.3fs", + close_elapsed, + open_elapsed, + ) + except (RPCError, RequestException): + try: + restore_height = self._daemon_height() or 0 + gen_start = time.monotonic() + # generate_from_keys opens the wallet when successful. + backend.client.raw_request( + "generate_from_keys", + { + "restore_height": restore_height, + "filename": wallet_name, + "address": payment_address, + "viewkey": view_key, + "password": MONERO_WALLET_RPC_WALLET_PASSWORD, + }, + ) + backend.current_wallet = wallet_name + gen_elapsed = time.monotonic() - gen_start + open_elapsed = time.monotonic() - open_start + logger.info( + "wallet generate timing close=%.3fs open_fail=%.3fs generate=%.3fs", + close_elapsed, + open_elapsed, + gen_elapsed, + ) + except (RPCError, RequestException) as gen_exc: + if isinstance(gen_exc, RequestException): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Monero wallet RPC is unreachable", + ) from gen_exc + gen_message = self._rpc_error_message(gen_exc).lower() + if "wallet_files_doesnt_correspond" in gen_message or "file_exists" in gen_message: + expected_path = ( + f"{self._wallet_dir.rstrip('/')}/{wallet_name}" + if self._wallet_dir + else wallet_name + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Wallet file exists but could not be opened. Verify the wallet " + "password, wallet directory, and that the cache file matches the " + ".keys file. If needed, remove or rename the wallet cache file " + f"to rebuild it. Expected filename: {expected_path}" + ), + ) from gen_exc + raise + + def _ensure_daemon(self, backend: WalletBackend) -> None: + if not self._daemon_address: + return + try: + backend.client.raw_request( + "set_daemon", + {"address": self._daemon_address}, + ) + except (RPCError, RequestException) as exc: + self._raise_wallet_rpc_error(exc) + + @staticmethod + def _raise_wallet_rpc_error(exc: Exception) -> None: + if isinstance(exc, RequestException): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Monero wallet RPC is unreachable", + ) from exc + message = str(exc) + if "no connection to daemon" in message or "no_connection_to_daemon" in message: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Wallet RPC cannot reach the Monero daemon", + ) from exc + raise diff --git a/api/app/qr_codes.py b/api/app/qr_codes.py new file mode 100644 index 0000000..e46e335 --- /dev/null +++ b/api/app/qr_codes.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import base64 +import os +import tempfile +from dataclasses import dataclass +from typing import Any + +from fastapi import HTTPException, status +import qrcode +from qrcode.constants import ERROR_CORRECT_H +from PIL import Image + +from .formatting import format_xmr_amount +from .models import Invoice + + +@dataclass(frozen=True) +class QrSettings: + logo: str # "monero" | "none" | "custom" + logo_data_url: str | None = None + + +def invoice_qr_url(invoice_id: str) -> str: + return f"/qr/{invoice_id}.png" + + +def ensure_invoice_qr_png( + *, + invoice: Invoice, + storage_dir: str, + settings: QrSettings, +) -> str: + if not storage_dir: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="QR storage is not configured", + ) + os.makedirs(storage_dir, exist_ok=True) + filename = f"{invoice.id}.png" + path = os.path.join(storage_dir, filename) + if os.path.exists(path): + return path + png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings) + _atomic_write(path, png_bytes) + return path + + +def build_invoice_qr_png_bytes(*, invoice: Invoice, settings: QrSettings) -> bytes: + uri = build_monero_uri(invoice) + qr = qrcode.QRCode( + error_correction=ERROR_CORRECT_H, + border=2, + box_size=10, + ) + qr.add_data(uri) + qr.make(fit=True) + image = qr.make_image(fill_color="black", back_color="white").convert("RGBA") + + if settings.logo == "custom" and settings.logo_data_url: + logo = _load_logo_from_data_url(settings.logo_data_url) + image = _overlay_logo(image, logo) + + out = tempfile.SpooledTemporaryFile(max_size=1024 * 1024) + image.save(out, format="PNG", optimize=True) + out.seek(0) + return out.read() + + +def build_monero_uri(invoice: Invoice) -> str: + amount = format_xmr_amount(invoice.amount_xmr) + params: dict[str, str] = {"tx_amount": amount} + metadata = invoice.metadata_json or {} + if isinstance(metadata, dict): + recipient_name = metadata.get("recipient_name") + description = metadata.get("description") + if isinstance(recipient_name, str) and recipient_name.strip(): + params["recipient_name"] = recipient_name.strip() + if isinstance(description, str) and description.strip(): + params["tx_description"] = description.strip() + query = "&".join(f"{_url_escape(k)}={_url_escape(v)}" for k, v in params.items()) + return f"monero:{invoice.address}?{query}" + + +def resolve_qr_settings(invoice: Invoice) -> QrSettings: + metadata: Any = invoice.metadata_json or {} + if not isinstance(metadata, dict): + return QrSettings(logo="monero", logo_data_url=None) + qr_meta = metadata.get("qr") + if not isinstance(qr_meta, dict): + return QrSettings(logo="monero", logo_data_url=None) + logo = qr_meta.get("logo") + logo_data_url = qr_meta.get("logo_data_url") + if not isinstance(logo, str) or logo not in ("monero", "none", "custom"): + return QrSettings(logo="monero", logo_data_url=None) + if logo == "custom" and isinstance(logo_data_url, str) and logo_data_url.strip(): + return QrSettings(logo="custom", logo_data_url=logo_data_url.strip()) + return QrSettings(logo=logo, logo_data_url=None) + + +def _atomic_write(path: str, data: bytes) -> None: + directory = os.path.dirname(path) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory) + try: + with os.fdopen(fd, "wb") as tmp_file: + tmp_file.write(data) + tmp_file.flush() + os.fsync(tmp_file.fileno()) + os.replace(tmp_path, path) + finally: + try: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception: + pass + + +def _load_logo_from_data_url(data_url: str) -> Image.Image: + # Expected: data:image/png;base64,<...> + try: + header, encoded = data_url.split(",", 1) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="QR logo must be a data URL", + ) from exc + if ";base64" not in header: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="QR logo must be base64 encoded", + ) + try: + raw = base64.b64decode(encoded, validate=True) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="QR logo could not be decoded", + ) from exc + try: + from io import BytesIO + + logo = Image.open(BytesIO(raw)).convert("RGBA") + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="QR logo image format is not supported", + ) from exc + return logo + + +def _overlay_logo(qr_image: Image.Image, logo: Image.Image) -> Image.Image: + # Center a small logo with a white backing. + size = min(qr_image.size) + target_logo_size = int(size * 0.22) + if target_logo_size < 24: + return qr_image + logo = logo.copy() + logo.thumbnail((target_logo_size, target_logo_size), Image.LANCZOS) + + overlay = Image.new("RGBA", qr_image.size, (255, 255, 255, 0)) + box_size = int(size * 0.28) + box_offset = int((size - box_size) / 2) + box = Image.new("RGBA", (box_size, box_size), (255, 255, 255, 255)) + overlay.paste(box, (box_offset, box_offset)) + + logo_offset = (int((size - logo.size[0]) / 2), int((size - logo.size[1]) / 2)) + overlay.paste(logo, logo_offset, mask=logo) + + return Image.alpha_composite(qr_image, overlay) + + +def _url_escape(value: str) -> str: + from urllib.parse import quote + + return quote(value, safe="") diff --git a/api/app/rates.py b/api/app/rates.py new file mode 100644 index 0000000..ae969e0 --- /dev/null +++ b/api/app/rates.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from decimal import Decimal +import threading +import time +from typing import Any + +import requests + +from .config import COINGECKO_API_KEY + +COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price" +COINGECKO_SOURCE = "coingecko" +RATE_TTL_SECONDS = 60 + + +@dataclass(frozen=True) +class QuoteResult: + rate: Decimal + currency: str + source: str + quoted_at: datetime + + +_cache_lock = threading.Lock() +_cached_quote: QuoteResult | None = None +_cached_at: float | None = None + + +def get_xmr_rate(currency: str) -> QuoteResult: + global _cached_quote, _cached_at + normalized_currency = currency.strip().lower() + if not COINGECKO_API_KEY: + raise RuntimeError("CoinGecko API key is not configured") + + with _cache_lock: + if _cached_quote and _cached_at: + if time.monotonic() - _cached_at < RATE_TTL_SECONDS: + return _cached_quote + + params = { + "vs_currencies": normalized_currency, + "ids": "monero", + "x_cg_demo_api_key": COINGECKO_API_KEY, + } + response = requests.get(COINGECKO_URL, params=params, timeout=5) + response.raise_for_status() + data: dict[str, Any] = response.json() + rate_value = ( + data.get("monero", {}).get(normalized_currency) + if isinstance(data, dict) + else None + ) + if rate_value is None: + raise ValueError("Unsupported fiat currency at this time") + rate = Decimal(str(rate_value)) + quote = QuoteResult( + rate=rate, + currency=normalized_currency.upper(), + source=COINGECKO_SOURCE, + quoted_at=datetime.now(timezone.utc), + ) + with _cache_lock: + _cached_quote = quote + _cached_at = time.monotonic() + return quote diff --git a/api/app/reconciler.py b/api/app/reconciler.py new file mode 100644 index 0000000..8b439fa --- /dev/null +++ b/api/app/reconciler.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import logging +import time +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_DOWN + +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from .btcpay_webhooks import dispatch_btcpay_webhooks +from .config import INVOICE_RECONCILE_INTERVAL_SECONDS, LATE_PAYMENT_LOOKBACK_HOURS +from .db import SessionLocal +from .models import Invoice, InvoiceTransfer, User +from .monero_service import MoneroWalletService, TransferDetail +from .webhooks import dispatch_webhooks + +logger = logging.getLogger(__name__) + + +def main() -> None: + level_name = "INFO" + try: + level_name = __import__("os").getenv("LOG_LEVEL", "INFO") + except Exception: + level_name = "INFO" + level = getattr(logging, level_name.upper(), logging.INFO) + logging.basicConfig(level=level) + service = MoneroWalletService() + while True: + try: + _reconcile_invoices(service) + except Exception as exc: + logger.exception("Invoice reconcile failed: %s", exc) + time.sleep(INVOICE_RECONCILE_INTERVAL_SECONDS) + + +def _reconcile_invoices(service: MoneroWalletService) -> None: + db: Session = SessionLocal() + try: + now = datetime.now(timezone.utc) + late_cutoff = now - timedelta(hours=max(0, LATE_PAYMENT_LOOKBACK_HOURS)) + invoices = ( + db.query(Invoice) + .filter( + or_( + Invoice.status.in_(["pending", "payment_detected"]), + and_( + Invoice.status == "expired", + Invoice.expires_at.is_not(None), + Invoice.expires_at >= late_cutoff, + ), + ) + ) + .order_by(Invoice.created_at.asc()) + .all() + ) + user_groups: dict[object, list[Invoice]] = {} + for invoice in invoices: + if invoice.user_id is None: + logger.debug( + "Skipping invoice without user", + extra={"invoice_id": str(invoice.id)}, + ) + continue + user_groups.setdefault(invoice.user_id, []).append(invoice) + logger.debug( + "Reconciling %d invoices across %d users", + len(invoices), + len(user_groups), + ) + for user_id, user_invoices in user_groups.items(): + user = db.query(User).filter(User.id == user_id).first() + if user is None: + logger.debug( + "Skipping invoices with missing user", + extra={"user_id": str(user_id)}, + ) + continue + if not user.payment_address or not user.view_key_encrypted: + logger.debug( + "Skipping invoices without payment address", + extra={"user_id": str(user.id)}, + ) + continue + for invoice in user_invoices: + try: + transfers = service.get_transfers_for_address( + user=user, + address=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) + 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: + 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), "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 + ) + finally: + db.close() + + +def _sync_invoice_transfers( + db: Session, + *, + invoice: Invoice, + transfers: list[TransferDetail], +) -> bool: + existing = ( + db.query(InvoiceTransfer) + .filter(InvoiceTransfer.invoice_id == invoice.id) + .all() + ) + existing_by_txid = {transfer.txid: transfer for transfer in existing if transfer.txid} + seen_txids: set[str] = set() + changed = False + for transfer in transfers: + txid = transfer.txid + if not txid: + continue + seen_txids.add(txid) + stored = existing_by_txid.get(txid) + if stored is None: + db.add( + InvoiceTransfer( + invoice_id=invoice.id, + txid=txid, + amount_atomic=transfer.amount_atomic, + confirmations=transfer.confirmations, + timestamp=transfer.timestamp, + address=transfer.address, + ) + ) + changed = True + continue + if ( + stored.amount_atomic != transfer.amount_atomic + or stored.confirmations != transfer.confirmations + or stored.timestamp != transfer.timestamp + or stored.address != transfer.address + ): + stored.amount_atomic = transfer.amount_atomic + stored.confirmations = transfer.confirmations + 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 + return changed + + + + +def _xmr_to_atomic(amount: Decimal) -> int: + quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value( + rounding=ROUND_DOWN + ) + return int(quantized) + + +if __name__ == "__main__": + main() diff --git a/api/app/routes.py b/api/app/routes.py new file mode 100644 index 0000000..84ae959 --- /dev/null +++ b/api/app/routes.py @@ -0,0 +1,1214 @@ +import uuid +from typing import Any +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_DOWN +import csv +import io + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi.responses import StreamingResponse +import requests +from requests import RequestException +from sqlalchemy import func, or_ +from sqlalchemy.sql import cast +from sqlalchemy.types import String as SqlString +from sqlalchemy.orm import Session + +from .config import ( + DONATION_ACTIVE_INVOICE_LIMIT, + DONATION_EXPIRY_MINUTES, + DONATIONS_ENABLED, + FOUNDER_PAYMENT_ADDRESS, + FOUNDER_VIEW_KEY, + INVOICE_DEFAULT_EXPIRY_HOURS, + QR_STORAGE_DIR, +) +from .db import get_db +from .formatting import format_xmr_amount +from .models import Invoice, ProfileHistory, User, Webhook, WebhookDelivery +from monero.address import Address, IntegratedAddress, SubAddress +from .rates import get_xmr_rate +from .subaddress_allocator import MAX_SUBADDRESS_INDEX, create_subaddress_for_user +from .schemas import ( + ApiCredentialsResetRequest, + ApiCredentialsResetResponse, + DonationCreate, + InvoiceCreateUser, + InvoiceListResponse, + InvoiceResponse, + InvoiceStatusResponse, + LoginRequest, + LoginResponse, + ProfileResponse, + ProfileUpdate, + WebhookCreate, + WebhookDeliveryResponse, + WebhookResponse, +) +from .security import ( + decrypt_secret, + decrypt_api_key, + encrypt_secret, + encrypt_api_key, + generate_api_key, + generate_webhook_secret, + hash_api_key, + require_api_key, +) +from .webhooks import build_webhook_payload, dispatch_webhooks +from .qr_codes import ensure_invoice_qr_png, invoice_qr_url, resolve_qr_settings + +router = APIRouter() + +WEBHOOK_EVENTS = ( + "invoice.created", + "invoice.payment_detected", + "invoice.confirmed", + "invoice.expired", +) + + +def _require_donations_enabled() -> None: + if not DONATIONS_ENABLED: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Donations are disabled", +) + +MAX_QR_LOGO_DATA_URL_LENGTH = 120_000 + + +def _get_user_for_api_key(db: Session, api_key: str) -> User | None: + return db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first() + + +def _get_founder_user(db: Session) -> User: + if not FOUNDER_PAYMENT_ADDRESS or not FOUNDER_VIEW_KEY: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Donations are not configured", + ) + _validate_payment_address_and_view_key(FOUNDER_PAYMENT_ADDRESS, FOUNDER_VIEW_KEY) + user = ( + db.query(User) + .filter(User.payment_address == FOUNDER_PAYMENT_ADDRESS) + .first() + ) + if user is None: + api_key = generate_api_key() + webhook_secret = generate_webhook_secret() + user = User( + payment_address=FOUNDER_PAYMENT_ADDRESS, + view_key_encrypted=encrypt_secret(FOUNDER_VIEW_KEY), + api_key_hash=hash_api_key(api_key), + api_key_encrypted=encrypt_api_key(api_key), + webhook_secret_encrypted=encrypt_secret(webhook_secret), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + if decrypt_secret(user.view_key_encrypted) != FOUNDER_VIEW_KEY: + user.view_key_encrypted = encrypt_secret(FOUNDER_VIEW_KEY) + db.add(user) + db.commit() + db.refresh(user) + _ensure_webhook_secret(db, user) + return user + + +def _default_invoice_expiry() -> datetime: + return datetime.now(timezone.utc) + timedelta(hours=INVOICE_DEFAULT_EXPIRY_HOURS) + + +def _donation_invoice_expiry() -> datetime: + return datetime.now(timezone.utc) + timedelta(minutes=DONATION_EXPIRY_MINUTES) + + +def _is_donation_invoice(invoice: Invoice) -> bool: + metadata = invoice.metadata_json or {} + return isinstance(metadata, dict) and metadata.get("origin") == "donation" + + +def _ensure_webhook_secret(db: Session, user: User) -> str: + if user.webhook_secret_encrypted: + return decrypt_secret(user.webhook_secret_encrypted) + secret = generate_webhook_secret() + user.webhook_secret_encrypted = encrypt_secret(secret) + db.add(user) + db.commit() + db.refresh(user) + return secret + + +def _resolve_webhook_payload( + payload: WebhookCreate, +) -> tuple[str | None, list[str], dict[str, str] | None]: + event_urls = {key: str(value) for key, value in (payload.event_urls or {}).items()} + events = list(payload.events or []) + if event_urls: + for key in event_urls: + if key not in WEBHOOK_EVENTS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Webhook event is not supported", + ) + if events: + for event in events: + if event not in WEBHOOK_EVENTS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Webhook event is not supported", + ) + events_set = set(events) | set(event_urls.keys()) + if not events_set: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Select at least one webhook event", + ) + if payload.url is None and not event_urls: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Webhook URL is required", + ) + if payload.url is None: + missing = [event for event in events_set if event not in event_urls] + if missing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Webhook URL is required for all selected events", + ) + ordered_events = [event for event in WEBHOOK_EVENTS if event in events_set] + return (str(payload.url) if payload.url else None, ordered_events, event_urls or None) + + +def _invoice_url(invoice: Invoice, request: Request) -> str: + proto = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", "")) + if not host: + return f"/invoice/{invoice.id}" + return f"{proto}://{host}/invoice/{invoice.id}" + + +def _qr_url(invoice: Invoice, request: Request) -> str: + proto = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", "")) + path = invoice_qr_url(str(invoice.id)) + if not host: + return path + return f"{proto}://{host}{path}" + + +def _invoice_response( + invoice: Invoice, + request: Request, + warnings: list[str] | None = None, +) -> InvoiceResponse: + response = InvoiceResponse.model_validate(invoice) + metadata = invoice.metadata_json or {} + quote = metadata.get("quote") if isinstance(metadata, dict) else None + qr = metadata.get("qr") if isinstance(metadata, dict) else None + qr_logo = qr.get("logo") if isinstance(qr, dict) else None + qr_logo_data_url = qr.get("logo_data_url") if isinstance(qr, dict) else None + if not isinstance(qr_logo, str): + qr_logo = None + if qr_logo not in ("monero", "none", "custom"): + qr_logo = None + qr_logo_data_url = None + if qr_logo != "custom": + qr_logo_data_url = None + if not isinstance(qr_logo_data_url, str): + qr_logo_data_url = None + return response.model_copy( + update={ + "invoice_url": _invoice_url(invoice, request), + "qr_url": _qr_url(invoice, request), + "warnings": warnings, + "quote": quote, + "qr_logo": qr_logo, + "qr_logo_data_url": qr_logo_data_url, + } + ) + + +def _public_invoice_status_response( + db: Session, + invoice: Invoice, + request: Request, +) -> InvoiceStatusResponse: + response = InvoiceStatusResponse.model_validate(invoice) + metadata = invoice.metadata_json or {} + update: dict[str, Any] = {} + btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None + if isinstance(btcpay_data, dict): + amount = btcpay_data.get("amount") + currency = btcpay_data.get("currency") + if isinstance(amount, str) and isinstance(currency, str): + update["btcpay_amount"] = amount + update["btcpay_currency"] = currency + if invoice.user_id: + user = db.query(User).filter(User.id == invoice.user_id).first() + if user and user.btcpay_checkout_style: + update["btcpay_checkout_style"] = user.btcpay_checkout_style + if isinstance(metadata, dict) and isinstance(metadata.get("quote"), dict): + update["quote"] = metadata.get("quote") + qr = metadata.get("qr") if isinstance(metadata, dict) else None + if isinstance(qr, dict): + logo = qr.get("logo") + logo_data_url = qr.get("logo_data_url") + if isinstance(logo, str) and logo in ("monero", "none", "custom"): + update["qr_logo"] = logo + if logo == "custom" and isinstance(logo_data_url, str): + update["qr_logo_data_url"] = logo_data_url + update["qr_url"] = _qr_url(invoice, request) + return response.model_copy(update=update) + + +def _resolve_invoice_amount( + db: Session, + *, + user: User, + requested_amount_xmr: Decimal | None = None, + requested_amount_fiat: Decimal | None = None, + requested_currency: str | None = None, +) -> tuple[Decimal, list[str] | None, dict[str, str] | None]: + if requested_amount_xmr is not None: + return requested_amount_xmr, None, None + if requested_amount_fiat is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="amount_xmr or amount_fiat is required", + ) + if not requested_currency: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="currency is required when amount_fiat is provided", + ) + try: + quote = get_xmr_rate(requested_currency) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Fiat quote service unavailable", + ) from exc + amount_xmr = (Decimal(requested_amount_fiat) / quote.rate).quantize( + Decimal("0.000000000001"), rounding=ROUND_DOWN + ) + warnings = ["Fiat conversion is an estimate and does not lock a rate."] + quote_payload = { + "fiat_amount": str(requested_amount_fiat), + "fiat_currency": quote.currency, + "rate": str(quote.rate), + "source": quote.source, + "quoted_at": quote.quoted_at.isoformat(), + } + return amount_xmr, warnings, quote_payload + + +def _validate_payment_address_and_view_key(payment_address: str, view_key: str) -> None: + try: + address = Address(payment_address) + except ValueError as exc: + try: + SubAddress(payment_address) + except ValueError: + try: + IntegratedAddress(payment_address) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Primary address is invalid", + ) from exc + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Integrated addresses are not supported. Use the primary address.", + ) from exc + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Subaddresses are not supported. Use the primary address.", + ) from exc + if not address.check_private_view_key(view_key): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Secret view key does not match the primary address", + ) + + +@router.post( + "/api/core/donations", + response_model=InvoiceResponse, + status_code=201, + include_in_schema=False, +) +def create_donation_invoice( + payload: DonationCreate, + request: Request, + db: Session = Depends(get_db), +): + _require_donations_enabled() + user = _get_founder_user(db) + expires_at = _donation_invoice_expiry() + active_limit = min(max(1, DONATION_ACTIVE_INVOICE_LIMIT), MAX_SUBADDRESS_INDEX) + now = datetime.now(timezone.utc) + active_count = ( + db.query(func.count(Invoice.id)) + .filter( + Invoice.user_id == user.id, + Invoice.status.in_(["pending", "payment_detected"]), + or_(Invoice.expires_at.is_(None), Invoice.expires_at > now), + ) + .scalar() + or 0 + ) + if active_count >= active_limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Donation traffic is high. Try again soon.", + ) + resolved_amount, warnings, _ = _resolve_invoice_amount( + db, + user=user, + requested_amount_xmr=payload.amount_xmr, + ) + address, subaddress_index = create_subaddress_for_user(db, user=user) + invoice = Invoice( + user_id=user.id, + wallet_address=user.payment_address, + address=address, + subaddress_index=subaddress_index, + amount_xmr=resolved_amount, + status="pending", + confirmation_target=payload.confirmation_target, + metadata_json={"origin": "donation"}, + expires_at=expires_at, + ) + db.add(invoice) + db.commit() + db.refresh(invoice) + try: + ensure_invoice_qr_png( + invoice=invoice, + storage_dir=QR_STORAGE_DIR, + settings=resolve_qr_settings(invoice), + ) + except HTTPException: + pass + return _invoice_response(invoice, request, warnings=warnings) + + +@router.get( + "/api/core/public/invoice/{invoice_id}", + response_model=InvoiceStatusResponse, +) +def get_invoice_status( + invoice_id: uuid.UUID, + request: Request, + db: Session = Depends(get_db), +): + invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() + if invoice is None or _is_donation_invoice(invoice): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + try: + ensure_invoice_qr_png( + invoice=invoice, + storage_dir=QR_STORAGE_DIR, + settings=resolve_qr_settings(invoice), + ) + except HTTPException: + pass + return _public_invoice_status_response(db, invoice, request) + + +@router.get( + "/api/core/public/donation/{invoice_id}", + response_model=InvoiceStatusResponse, + include_in_schema=False, +) +def get_donation_status( + invoice_id: uuid.UUID, + request: Request, + db: Session = Depends(get_db), +): + _require_donations_enabled() + founder = _get_founder_user(db) + invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() + if ( + invoice is None + or not _is_donation_invoice(invoice) + or invoice.user_id != founder.id + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Donation invoice not found", + ) + try: + ensure_invoice_qr_png( + invoice=invoice, + storage_dir=QR_STORAGE_DIR, + settings=resolve_qr_settings(invoice), + ) + except HTTPException: + pass + return _public_invoice_status_response(db, invoice, request) + + +@router.post("/api/core/webhooks", response_model=WebhookResponse) +def register_webhook( + payload: WebhookCreate, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + _ensure_webhook_secret(db, user) + url, events, event_urls = _resolve_webhook_payload(payload) + webhook = Webhook( + user_id=user.id, + url=url, + events=events, + event_urls=event_urls, + active=True, + ) + db.add(webhook) + db.commit() + db.refresh(webhook) + return webhook + + +@router.get("/api/core/webhooks", response_model=list[WebhookResponse]) +def list_webhooks( + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + return ( + db.query(Webhook) + .filter(Webhook.user_id == user.id) + .order_by(Webhook.created_at.desc()) + .all() + ) + + +@router.get("/api/core/webhooks/history", response_model=list[WebhookDeliveryResponse]) +def list_webhook_history( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + return ( + db.query(WebhookDelivery) + .filter(WebhookDelivery.user_id == user.id) + .order_by(WebhookDelivery.created_at.desc()) + .limit(limit) + .offset(offset) + .all() + ) + + +@router.post( + "/api/core/webhooks/deliveries/{delivery_id}/redeliver", + status_code=200, +) +def redeliver_webhook_delivery( + delivery_id: uuid.UUID, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + delivery = ( + db.query(WebhookDelivery) + .filter(WebhookDelivery.id == delivery_id, WebhookDelivery.user_id == user.id) + .first() + ) + if delivery is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Webhook delivery not found", + ) + if delivery.http_status is not None and delivery.http_status < 400: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Only failed deliveries can be redelivered", + ) + payload = delivery.payload_json + if not isinstance(payload, dict): + payload = None + if payload is None and delivery.invoice_id: + invoice = db.query(Invoice).filter(Invoice.id == delivery.invoice_id).first() + if invoice is not None: + payload = build_webhook_payload(delivery.event, invoice) + if payload is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Webhook payload is unavailable for this delivery", + ) + + webhook_secret = _ensure_webhook_secret(db, user) + status_code = None + error_message = None + try: + response = requests.post( + delivery.url, + json=payload, + headers={"X-Webhook-Secret": webhook_secret}, + timeout=5, + ) + status_code = response.status_code + except RequestException as exc: + error_message = str(exc) + + redelivery = WebhookDelivery( + user_id=user.id, + webhook_id=delivery.webhook_id, + event=delivery.event, + url=delivery.url, + invoice_id=delivery.invoice_id, + invoice_address=delivery.invoice_address, + invoice_subaddress_index=delivery.invoice_subaddress_index, + invoice_amount_xmr=delivery.invoice_amount_xmr, + invoice_status=delivery.invoice_status, + payload_json=payload, + http_status=status_code, + error_message=error_message, + ) + db.add(redelivery) + db.commit() + db.refresh(redelivery) + return { + "delivery_id": str(redelivery.id), + "http_status": redelivery.http_status, + "error_message": redelivery.error_message, + } + + +@router.delete( + "/api/core/webhooks/{webhook_id}", + status_code=204, + responses={204: {"description": "Webhook deleted"}}, +) +def delete_webhook( + webhook_id: uuid.UUID, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + webhook = ( + db.query(Webhook) + .filter(Webhook.id == webhook_id, Webhook.user_id == user.id) + .first() + ) + if webhook is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Webhook not found", + ) + db.delete(webhook) + db.commit() + return None + + +@router.post("/api/core/auth/login", response_model=LoginResponse) +def login_user(payload: LoginRequest, db: Session = Depends(get_db)): + payment_address = payload.payment_address.strip() + view_key = payload.view_key.strip() + _validate_payment_address_and_view_key(payment_address, view_key) + user = db.query(User).filter(User.payment_address == payment_address).first() + if user is None: + api_key = generate_api_key() + webhook_secret = generate_webhook_secret() + user = User( + payment_address=payment_address, + view_key_encrypted=encrypt_secret(view_key), + api_key_hash=hash_api_key(api_key), + api_key_encrypted=encrypt_api_key(api_key), + webhook_secret_encrypted=encrypt_secret(webhook_secret), + ) + db.add(user) + db.commit() + db.refresh(user) + return { + "api_key": api_key, + "webhook_secret": webhook_secret, + "store_id": user.id, + } + current_view_key = decrypt_secret(user.view_key_encrypted) + if current_view_key != view_key: + history_entry = ProfileHistory( + user_id=user.id, + field_name="view_key", + old_value=user.view_key_encrypted, + new_value=encrypt_secret(view_key), + value_encrypted=True, + ) + user.view_key_encrypted = encrypt_secret(view_key) + db.add(history_entry) + db.commit() + db.refresh(user) + webhook_secret = _ensure_webhook_secret(db, user) + return { + "api_key": decrypt_api_key(user.api_key_encrypted), + "webhook_secret": webhook_secret, + "store_id": user.id, + } + + +@router.post("/api/core/auth/validate", status_code=status.HTTP_204_NO_CONTENT) +def validate_login(payload: LoginRequest) -> Response: + payment_address = payload.payment_address.strip() + view_key = payload.view_key.strip() + _validate_payment_address_and_view_key(payment_address, view_key) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/api/core/api-credentials/reset", + response_model=ApiCredentialsResetResponse, +) +def reset_api_credentials( + payload: ApiCredentialsResetRequest, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + if not payload.reset_api_key and not payload.reset_webhook_secret: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Select at least one credential to reset", + ) + response: dict[str, str] = {} + if payload.reset_api_key: + new_key = generate_api_key() + new_key_encrypted = encrypt_api_key(new_key) + history_entry = ProfileHistory( + user_id=user.id, + field_name="api_key", + old_value=user.api_key_encrypted, + new_value=new_key_encrypted, + value_encrypted=True, + ) + user.api_key_hash = hash_api_key(new_key) + user.api_key_encrypted = new_key_encrypted + db.add(history_entry) + response["api_key"] = new_key + if payload.reset_webhook_secret: + new_secret = generate_webhook_secret() + new_secret_encrypted = encrypt_secret(new_secret) + history_entry = ProfileHistory( + user_id=user.id, + field_name="webhook_secret", + old_value=user.webhook_secret_encrypted, + new_value=new_secret_encrypted, + value_encrypted=True, + ) + user.webhook_secret_encrypted = new_secret_encrypted + db.add(history_entry) + response["webhook_secret"] = new_secret + db.commit() + db.refresh(user) + return response + + +@router.get("/api/core/profile", response_model=ProfileResponse) +def get_profile( + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + return user + + +@router.patch("/api/core/profile", response_model=ProfileResponse) +def update_profile( + payload: ProfileUpdate, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + if not payload.model_fields_set: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Select at least one profile field to update", + ) + if ( + "btcpay_checkout_style" in payload.model_fields_set + and payload.btcpay_checkout_style != user.btcpay_checkout_style + ): + history_entry = ProfileHistory( + user_id=user.id, + field_name="btcpay_checkout_style", + old_value=user.btcpay_checkout_style, + new_value=payload.btcpay_checkout_style, + value_encrypted=False, + ) + user.btcpay_checkout_style = payload.btcpay_checkout_style + db.add(history_entry) + if ( + "default_confirmation_target" in payload.model_fields_set + and payload.default_confirmation_target is not None + and payload.default_confirmation_target != user.default_confirmation_target + ): + history_entry = ProfileHistory( + user_id=user.id, + field_name="default_confirmation_target", + old_value=str(user.default_confirmation_target), + new_value=str(payload.default_confirmation_target), + value_encrypted=False, + ) + user.default_confirmation_target = payload.default_confirmation_target + db.add(history_entry) + if "default_qr_logo" in payload.model_fields_set: + if payload.default_qr_logo is not None and payload.default_qr_logo != user.default_qr_logo: + history_entry = ProfileHistory( + user_id=user.id, + field_name="default_qr_logo", + old_value=user.default_qr_logo, + new_value=payload.default_qr_logo, + value_encrypted=False, + ) + user.default_qr_logo = payload.default_qr_logo + db.add(history_entry) + if "default_qr_logo_data_url" in payload.model_fields_set: + if payload.default_qr_logo_data_url is not None: + value = payload.default_qr_logo_data_url.strip() + if value and len(value) > MAX_QR_LOGO_DATA_URL_LENGTH: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="QR logo image is too large", + ) + if value == "": + payload.default_qr_logo_data_url = None + if payload.default_qr_logo_data_url != user.default_qr_logo_data_url: + history_entry = ProfileHistory( + user_id=user.id, + field_name="default_qr_logo_data_url", + old_value=user.default_qr_logo_data_url, + new_value=payload.default_qr_logo_data_url, + value_encrypted=False, + ) + user.default_qr_logo_data_url = payload.default_qr_logo_data_url + db.add(history_entry) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/api/core/invoices", response_model=InvoiceResponse, status_code=201) +def create_invoice_for_user( + payload: InvoiceCreateUser, + request: Request, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + if not user.payment_address or not user.view_key_encrypted: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Primary address is not configured for this user", + ) + confirmation_target = payload.confirmation_target + if "confirmation_target" not in payload.model_fields_set: + confirmation_target = user.default_confirmation_target + expires_at = payload.expires_at or _default_invoice_expiry() + resolved_amount, warnings, quote = _resolve_invoice_amount( + db, + user=user, + requested_amount_xmr=payload.amount_xmr, + requested_amount_fiat=payload.amount_fiat, + requested_currency=payload.currency, + ) + if warnings is not None and len(warnings) == 0: + warnings = None + metadata = payload.metadata or {} + if quote: + metadata = {**metadata, "quote": quote} + if isinstance(metadata, dict): + qr = metadata.get("qr") + if not isinstance(qr, dict): + qr = None + if qr is None: + qr = {"logo": user.default_qr_logo} + if user.default_qr_logo == "custom" and user.default_qr_logo_data_url: + qr["logo_data_url"] = user.default_qr_logo_data_url + metadata = {**metadata, "qr": qr} + else: + logo = qr.get("logo") + logo_data_url = qr.get("logo_data_url") + if isinstance(logo, str) and logo in ("monero", "none", "custom"): + normalized = {"logo": logo} + if logo == "custom": + if isinstance(logo_data_url, str): + trimmed = logo_data_url.strip() + if trimmed and len(trimmed) > MAX_QR_LOGO_DATA_URL_LENGTH: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="QR logo image is too large", + ) + if trimmed: + normalized["logo_data_url"] = trimmed + metadata = {**metadata, "qr": normalized} + else: + metadata = {"qr": {"logo": user.default_qr_logo}} + if user.default_qr_logo == "custom" and user.default_qr_logo_data_url: + metadata["qr"]["logo_data_url"] = user.default_qr_logo_data_url + address, subaddress_index = create_subaddress_for_user(db, user=user) + invoice = Invoice( + user_id=user.id, + wallet_address=user.payment_address, + address=address, + subaddress_index=subaddress_index, + amount_xmr=resolved_amount, + status="pending", + confirmation_target=confirmation_target, + total_paid_atomic=0, + metadata_json=metadata, + expires_at=expires_at, + ) + db.add(invoice) + db.commit() + db.refresh(invoice) + try: + ensure_invoice_qr_png( + invoice=invoice, + storage_dir=QR_STORAGE_DIR, + settings=resolve_qr_settings(invoice), + ) + except HTTPException: + pass + dispatch_webhooks(db, str(user.id), "invoice.created", invoice) + return _invoice_response(invoice, request, warnings=warnings) + + +@router.get("/api/core/invoices", response_model=InvoiceListResponse) +def list_invoices_for_user( + request: Request, + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + status_filter: str | None = Query(default=None, alias="status"), + include_archived: bool = Query(default=False, alias="include_archived"), + q: str | None = Query(default=None), + sort: str = Query(default="created_at"), + order: str = Query(default="desc"), + created_from: datetime | None = Query(default=None), + created_to: datetime | None = Query(default=None), + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + query = db.query(Invoice).filter(Invoice.user_id == user.id) + if not include_archived: + query = query.filter(Invoice.archived_at.is_(None)) + if status_filter: + query = query.filter(Invoice.status == status_filter) + if q: + needle = q.strip() + if needle: + try: + invoice_uuid = uuid.UUID(needle) + except ValueError: + invoice_uuid = None + matchers = [] + if invoice_uuid is not None: + matchers.append(Invoice.id == invoice_uuid) + matchers.append(Invoice.address.ilike(f"%{needle}%")) + matchers.append(cast(Invoice.metadata_json, SqlString).ilike(f"%{needle}%")) + query = query.filter(or_(*matchers)) + if created_from: + query = query.filter(Invoice.created_at >= created_from) + if created_to: + query = query.filter(Invoice.created_at <= created_to) + + sort_keys = { + "created_at": Invoice.created_at, + "expires_at": Invoice.expires_at, + "amount_xmr": Invoice.amount_xmr, + "status": Invoice.status, + "confirmations": Invoice.confirmations, + "confirmation_target": Invoice.confirmation_target, + } + sort_column = sort_keys.get(sort, Invoice.created_at) + if order.lower() == "asc": + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(sort_column.desc()) + items = ( + query.offset(offset) + .limit(limit) + .all() + ) + invoice_items = [_invoice_response(invoice, request) for invoice in items] + total_query = db.query(func.count(Invoice.id)).filter(Invoice.user_id == user.id) + if not include_archived: + total_query = total_query.filter(Invoice.archived_at.is_(None)) + if status_filter: + total_query = total_query.filter(Invoice.status == status_filter) + if q: + needle = q.strip() + if needle: + try: + invoice_uuid = uuid.UUID(needle) + except ValueError: + invoice_uuid = None + matchers = [] + if invoice_uuid is not None: + matchers.append(Invoice.id == invoice_uuid) + matchers.append(Invoice.address.ilike(f"%{needle}%")) + matchers.append(cast(Invoice.metadata_json, SqlString).ilike(f"%{needle}%")) + total_query = total_query.filter(or_(*matchers)) + if created_from: + total_query = total_query.filter(Invoice.created_at >= created_from) + if created_to: + total_query = total_query.filter(Invoice.created_at <= created_to) + total = total_query.scalar() or 0 + return {"items": invoice_items, "total": total} + + +@router.get( + "/api/core/invoices/export.csv", + responses={200: {"content": {"text/csv": {}}}}, +) +def export_invoices_csv( + include_archived: bool = Query(default=False, alias="include_archived"), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None), + sort: str = Query(default="created_at"), + order: str = Query(default="desc"), + created_from: datetime | None = Query(default=None), + created_to: datetime | None = Query(default=None), + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + + query = db.query(Invoice).filter(Invoice.user_id == user.id) + if not include_archived: + query = query.filter(Invoice.archived_at.is_(None)) + if status_filter: + query = query.filter(Invoice.status == status_filter) + if q: + needle = q.strip() + if needle: + try: + invoice_uuid = uuid.UUID(needle) + except ValueError: + invoice_uuid = None + matchers = [] + if invoice_uuid is not None: + matchers.append(Invoice.id == invoice_uuid) + matchers.append(Invoice.address.ilike(f"%{needle}%")) + matchers.append(cast(Invoice.metadata_json, SqlString).ilike(f"%{needle}%")) + query = query.filter(or_(*matchers)) + if created_from: + query = query.filter(Invoice.created_at >= created_from) + if created_to: + query = query.filter(Invoice.created_at <= created_to) + + sort_keys = { + "created_at": Invoice.created_at, + "expires_at": Invoice.expires_at, + "amount_xmr": Invoice.amount_xmr, + "status": Invoice.status, + "confirmations": Invoice.confirmations, + "confirmation_target": Invoice.confirmation_target, + } + sort_column = sort_keys.get(sort, Invoice.created_at) + if order.lower() == "asc": + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(sort_column.desc()) + + def _row_writer(): + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow( + [ + "id", + "status", + "amount_xmr", + "confirmation_target", + "confirmations", + "paid_after_expiry", + "paid_after_expiry_at", + "address", + "subaddress_index", + "created_at", + "expires_at", + "detected_at", + "confirmed_at", + "archived_at", + "metadata", + ] + ) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + + for invoice in query.yield_per(500): + metadata = invoice.metadata_json + if metadata is None: + metadata_value = "" + else: + metadata_value = str(metadata) + writer.writerow( + [ + str(invoice.id), + invoice.status, + format_xmr_amount(invoice.amount_xmr), + invoice.confirmation_target, + invoice.confirmations or 0, + bool(invoice.paid_after_expiry), + invoice.paid_after_expiry_at.isoformat() + if invoice.paid_after_expiry_at + else "", + invoice.address, + invoice.subaddress_index if invoice.subaddress_index is not None else "", + invoice.created_at.isoformat() if invoice.created_at else "", + invoice.expires_at.isoformat() if invoice.expires_at else "", + invoice.detected_at.isoformat() if invoice.detected_at else "", + invoice.confirmed_at.isoformat() if invoice.confirmed_at else "", + invoice.archived_at.isoformat() if invoice.archived_at else "", + metadata_value, + ] + ) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + + filename = f"invoices-{datetime.now(timezone.utc).date().isoformat()}.csv" + headers = {"Content-Disposition": f'attachment; filename="{filename}"'} + return StreamingResponse(_row_writer(), media_type="text/csv", headers=headers) + + +@router.get("/api/core/invoices/{invoice_id}", response_model=InvoiceResponse) +def get_invoice_for_user( + invoice_id: uuid.UUID, + request: Request, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + invoice = ( + db.query(Invoice) + .filter( + Invoice.id == invoice_id, + Invoice.user_id == user.id, + Invoice.archived_at.is_(None), + ) + .first() + ) + if invoice is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + return _invoice_response(invoice, request) + + +@router.delete( + "/api/core/invoices/{invoice_id}", + status_code=204, + responses={204: {"description": "Invoice archived"}}, +) +def archive_invoice_for_user( + invoice_id: uuid.UUID, + db: Session = Depends(get_db), + api_key: str = Depends(require_api_key), +): + user = _get_user_for_api_key(db, api_key) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User API key required", + ) + invoice = ( + db.query(Invoice) + .filter( + Invoice.id == invoice_id, + Invoice.user_id == user.id, + Invoice.archived_at.is_(None), + ) + .first() + ) + if invoice is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invoice not found", + ) + if invoice.status not in ("pending", "expired", "invalid"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Only pending, expired, or invalid invoices can be archived", + ) + invoice.archived_at = datetime.now(timezone.utc) + db.add(invoice) + db.commit() + return None diff --git a/api/app/schemas.py b/api/app/schemas.py new file mode 100644 index 0000000..72e0047 --- /dev/null +++ b/api/app/schemas.py @@ -0,0 +1,243 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any, Literal +from uuid import UUID + +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + Field, + conint, + field_serializer, + field_validator, + model_validator, +) + +from .formatting import format_xmr_amount + +InvoiceStatus = Literal["pending", "payment_detected", "confirmed", "expired", "invalid"] +BtcpayCheckoutStyle = Literal["standard", "btcpay_classic"] +QrLogoMode = Literal["monero", "none", "custom"] + + +class InvoiceCreate(BaseModel): + amount_xmr: Decimal | None = Field(default=None, gt=0) + amount_fiat: Decimal | None = Field(default=None, gt=0) + currency: str | None = None + confirmation_target: conint(ge=0, le=10) = 10 + metadata: dict[str, Any] | None = None + expires_at: datetime | None = Field( + default=None, + description="Defaults to 24 hours from creation if omitted.", + ) + + @field_validator("currency") + @classmethod + def _normalize_currency(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip().upper() + + @model_validator(mode="after") + def _validate_amounts(self) -> "InvoiceCreate": + if self.amount_xmr is None and self.amount_fiat is None: + raise ValueError("amount_xmr or amount_fiat is required") + if self.amount_xmr is not None and self.amount_fiat is not None: + raise ValueError("Provide either amount_xmr or amount_fiat, not both") + if self.amount_fiat is not None and not self.currency: + raise ValueError("currency is required when amount_fiat is provided") + return self + + +class DonationCreate(BaseModel): + amount_xmr: Decimal = Field(gt=0) + confirmation_target: conint(ge=0, le=10) = 10 + + +class InvoiceResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + address: str + subaddress_index: int | None = None + amount_xmr: Decimal + status: InvoiceStatus + confirmation_target: int + confirmations: int = 0 + paid_after_expiry: bool = False + paid_after_expiry_at: datetime | None = None + qr_logo: QrLogoMode | None = None + qr_logo_data_url: str | None = None + metadata: dict[str, Any] | None = Field(default=None, validation_alias="metadata_json") + warnings: list[str] | None = None + quote: dict[str, Any] | None = None + invoice_url: str | None = None + qr_url: str | None = None + created_at: datetime + archived_at: datetime | None = None + expires_at: datetime | None + detected_at: datetime | None + confirmed_at: datetime | None + + @field_serializer("amount_xmr") + def _serialize_amount_xmr(self, value: Decimal) -> str: + return format_xmr_amount(value) + + +class InvoiceCreateUser(BaseModel): + amount_xmr: Decimal | None = Field(default=None, gt=0) + amount_fiat: Decimal | None = Field(default=None, gt=0) + currency: str | None = None + confirmation_target: conint(ge=0, le=10) = 10 + metadata: dict[str, Any] | None = None + expires_at: datetime | None = Field( + default=None, + description="Defaults to 24 hours from creation if omitted.", + ) + + @field_validator("currency") + @classmethod + def _normalize_currency(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip().upper() + + @model_validator(mode="after") + def _validate_amounts(self) -> "InvoiceCreateUser": + if self.amount_xmr is None and self.amount_fiat is None: + raise ValueError("amount_xmr or amount_fiat is required") + if self.amount_xmr is not None and self.amount_fiat is not None: + raise ValueError("Provide either amount_xmr or amount_fiat, not both") + if self.amount_fiat is not None and not self.currency: + raise ValueError("currency is required when amount_fiat is provided") + return self + + +class InvoiceListResponse(BaseModel): + items: list[InvoiceResponse] + total: int + + +class InvoiceStatusResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + address: str + subaddress_index: int | None = None + amount_xmr: Decimal + status: InvoiceStatus + confirmation_target: int + confirmations: int = 0 + paid_after_expiry: bool = False + paid_after_expiry_at: datetime | None = None + qr_logo: QrLogoMode | None = None + qr_logo_data_url: str | None = None + created_at: datetime + expires_at: datetime | None + detected_at: datetime | None + confirmed_at: datetime | None + btcpay_amount: str | None = None + btcpay_currency: str | None = None + btcpay_checkout_style: BtcpayCheckoutStyle | None = None + quote: dict[str, Any] | None = None + qr_url: str | None = None + + @field_serializer("amount_xmr") + def _serialize_amount_xmr(self, value: Decimal) -> str: + return format_xmr_amount(value) + + +class ProfileResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + payment_address: str + default_confirmation_target: conint(ge=0, le=10) = 10 + default_qr_logo: QrLogoMode = "monero" + default_qr_logo_data_url: str | None = None + btcpay_checkout_style: BtcpayCheckoutStyle = "standard" + created_at: datetime + + +class ProfileUpdate(BaseModel): + btcpay_checkout_style: BtcpayCheckoutStyle | None = None + default_confirmation_target: conint(ge=0, le=10) | None = None + default_qr_logo: QrLogoMode | None = None + default_qr_logo_data_url: str | None = None + + +class WebhookCreate(BaseModel): + url: AnyUrl | None = None + events: list[ + Literal[ + "invoice.created", + "invoice.payment_detected", + "invoice.confirmed", + "invoice.expired", + ] + ] | None = None + event_urls: dict[ + Literal[ + "invoice.created", + "invoice.payment_detected", + "invoice.confirmed", + "invoice.expired", + ], + AnyUrl, + ] | None = None + + +class WebhookResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + url: AnyUrl | None = None + events: list[str] + event_urls: dict[str, AnyUrl] | None = None + active: bool + created_at: datetime + + +class WebhookDeliveryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + webhook_id: UUID | None = None + event: str + url: str + invoice_id: UUID | None = None + invoice_address: str | None = None + invoice_subaddress_index: int | None = None + invoice_amount_xmr: Decimal | None = None + invoice_status: InvoiceStatus | None = None + http_status: int | None = None + error_message: str | None = None + created_at: datetime + + @field_serializer("invoice_amount_xmr") + def _serialize_invoice_amount_xmr(self, value: Decimal | None) -> str | None: + if value is None: + return None + return format_xmr_amount(value) + + +class LoginRequest(BaseModel): + payment_address: str = Field(min_length=1) + view_key: str = Field(min_length=1, max_length=256) + + +class LoginResponse(BaseModel): + api_key: str + webhook_secret: str + store_id: UUID + + +class ApiCredentialsResetRequest(BaseModel): + reset_api_key: bool = False + reset_webhook_secret: bool = False + + +class ApiCredentialsResetResponse(BaseModel): + api_key: str | None = None + webhook_secret: str | None = None diff --git a/api/app/security.py b/api/app/security.py new file mode 100644 index 0000000..e95b56b --- /dev/null +++ b/api/app/security.py @@ -0,0 +1,89 @@ +import hashlib +import secrets + +from fastapi import Depends, Header, HTTPException, status +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from .config import ( + API_KEYS, + API_KEY_ENCRYPTION_KEY, +) +from .db import get_db +from .models import User + +_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + + +def _parse_authorization_api_key(authorization: str | None) -> str | None: + if not authorization: + return None + scheme, _, token = authorization.partition(" ") + if scheme.lower() not in ("apikey", "token"): + return None + token = token.strip() + return token or None + + +def require_api_key( + authorization: str | None = Header(default=None), + x_api_key: str | None = Header(default=None), + db: Session = Depends(get_db), +) -> str: + api_key = _parse_authorization_api_key(authorization) or x_api_key + if api_key is None or api_key not in API_KEYS: + if api_key is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + ) + api_key_hash = hash_api_key(api_key) + user = db.query(User).filter(User.api_key_hash == api_key_hash).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + ) + return api_key + + +def hash_password(password: str) -> str: + return _pwd_context.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + return _pwd_context.verify(password, password_hash) + + +def generate_api_key() -> str: + return f"xmrcheckout_{secrets.token_urlsafe(32)}" + + +def generate_webhook_secret() -> str: + return f"whsec_{secrets.token_urlsafe(32)}" + + +def hash_api_key(api_key: str) -> str: + return hashlib.sha256(api_key.encode("utf-8")).hexdigest() + + +def encrypt_api_key(api_key: str) -> str: + return encrypt_secret(api_key) + + +def decrypt_api_key(api_key_encrypted: str) -> str: + return decrypt_secret(api_key_encrypted) + + +def encrypt_secret(value: str) -> str: + from cryptography.fernet import Fernet + + return Fernet(API_KEY_ENCRYPTION_KEY).encrypt(value.encode("utf-8")).decode("utf-8") + + +def decrypt_secret(value_encrypted: str) -> str: + from cryptography.fernet import Fernet + + return Fernet(API_KEY_ENCRYPTION_KEY).decrypt( + value_encrypted.encode("utf-8") + ).decode("utf-8") diff --git a/api/app/subaddress_allocator.py b/api/app/subaddress_allocator.py new file mode 100644 index 0000000..679e187 --- /dev/null +++ b/api/app/subaddress_allocator.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from .models import User +from .security import decrypt_secret +from .subaddress_derivation import derive_subaddress + + +MAX_SUBADDRESS_INDEX = 100 + + +def create_subaddress_for_user( + db: Session, + *, + user: User, +) -> tuple[str, int | None]: + locked = ( + db.query(User) + .filter( + User.id == user.id, + ) + .with_for_update() + .first() + ) + if locked is None: + raise ValueError("User not found") + start_index = int(locked.subaddress_start_index or 0) + if start_index < 0 or start_index >= MAX_SUBADDRESS_INDEX: + start_index = 0 + range_start = start_index + 1 + range_end = MAX_SUBADDRESS_INDEX + next_index = int(locked.next_subaddress_index or range_start) + if next_index < range_start or next_index > range_end: + next_index = range_start + next_next_index = next_index + 1 + if next_next_index > range_end: + next_next_index = range_start + locked.next_subaddress_index = next_next_index + db.flush() + view_key = decrypt_secret(locked.view_key_encrypted) + address = derive_subaddress( + payment_address=locked.payment_address, + view_key=view_key, + account_index=0, + address_index=next_index, + ) + return address, int(next_index) diff --git a/api/app/subaddress_derivation.py b/api/app/subaddress_derivation.py new file mode 100644 index 0000000..915dc29 --- /dev/null +++ b/api/app/subaddress_derivation.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from binascii import hexlify, unhexlify +import struct + +from monero import base58, const, ed25519 +from monero.address import Address +from monero.keccak import keccak_256 + + +def derive_subaddress( + *, + payment_address: str, + view_key: str, + account_index: int, + address_index: int, +) -> str: + address = Address(payment_address) + view_key_bytes = unhexlify(view_key) + spend_pub_bytes = unhexlify(address.spend_key()) + + data = ( + b"SubAddr\0" + + view_key_bytes + + struct.pack(" dict[str, object]: + return { + "event": event, + "invoice": { + "id": str(invoice.id), + "address": invoice.address, + "subaddress_index": invoice.subaddress_index, + "amount_xmr": format_xmr_amount(invoice.amount_xmr), + "status": invoice.status, + "confirmation_target": invoice.confirmation_target, + "confirmations": invoice.confirmations or 0, + "paid_after_expiry": bool(invoice.paid_after_expiry), + "paid_after_expiry_at": _isoformat(invoice.paid_after_expiry_at), + "metadata": invoice.metadata_json, + "created_at": _isoformat(invoice.created_at), + "expires_at": _isoformat(invoice.expires_at), + "detected_at": _isoformat(invoice.detected_at), + "confirmed_at": _isoformat(invoice.confirmed_at), + }, + } + + +def dispatch_webhooks( + db: Session, + user_id: str, + event: str, + invoice: Invoice, +) -> None: + hooks = ( + db.query(Webhook) + .filter( + Webhook.user_id == user_id, + Webhook.active.is_(True), + ) + .all() + ) + user = db.query(User).filter(User.id == user_id).first() + webhook_secret = None + if user is not None: + webhook_secret = _ensure_webhook_secret(db, user) + payload = build_webhook_payload(event, invoice) + deliveries: list[WebhookDelivery] = [] + user_uuid = uuid.UUID(user_id) + for hook in hooks: + if event not in hook.events: + continue + hook_urls = hook.event_urls or {} + target_url = hook_urls.get(event, hook.url) + if not target_url: + logger.warning( + "Webhook URL missing for event", + extra={"webhook_id": str(hook.id), "event": event}, + ) + continue + status_code = None + error_message = None + try: + headers = {"X-Webhook-Secret": webhook_secret} if webhook_secret else None + response = requests.post(target_url, json=payload, headers=headers, timeout=5) + status_code = response.status_code + except RequestException as exc: + error_message = str(exc) + logger.warning( + "Webhook delivery failed", + extra={"webhook_id": str(hook.id), "event": event}, + ) + logger.debug("Webhook delivery error: %s", exc) + deliveries.append( + WebhookDelivery( + user_id=user_uuid, + webhook_id=hook.id, + event=event, + url=target_url, + invoice_id=invoice.id, + invoice_address=invoice.address, + invoice_subaddress_index=invoice.subaddress_index, + invoice_amount_xmr=invoice.amount_xmr, + invoice_status=invoice.status, + payload_json=payload, + http_status=status_code, + error_message=error_message, + ) + ) + if deliveries: + try: + db.add_all(deliveries) + db.commit() + except Exception as exc: + db.rollback() + logger.warning("Failed to save webhook delivery history", extra={"error": str(exc)}) + + +def _isoformat(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +def _ensure_webhook_secret(db: Session, user: User) -> str: + if user.webhook_secret_encrypted: + return decrypt_secret(user.webhook_secret_encrypted) + secret = generate_webhook_secret() + user.webhook_secret_encrypted = encrypt_secret(secret) + db.add(user) + db.commit() + db.refresh(user) + return secret diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..67fc206 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.0 +gunicorn==23.0.0 +uvicorn[standard]==0.30.6 +SQLAlchemy==2.0.36 +psycopg2-binary==2.9.9 +pydantic==2.9.2 +passlib[bcrypt]==1.7.4 +itsdangerous==2.2.0 +cryptography==42.0.8 +email-validator==2.2.0 +monero==1.1.1 +qrcode==7.4.2 +Pillow==11.1.0