commit f8f9b4424c1d0f85c447128611fa6909703a65fc Author: jwinterm Date: Mon Mar 9 11:08:55 2026 -0400 Fork xmrcheckout for Wownero (WOW) Minimal changes from upstream xmrcheckout to support Wownero: - Rate provider: Nonlogs WOW-BTC via Kraken BTC/USD - Subaddress derivation: Wownero network byte overrides (53/63/54) - Payment URI scheme: wownero: - Dockerfile: Wownero binaries from Codeberg - Display text: Monero→Wownero, XMR→WOW throughout UI - Default daemon: 192.168.88.235:34568 - Added wownero-logo.svg File and folder structure kept identical to upstream for easy sync. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e8c3b46 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Database +POSTGRES_USER=wowcheckout +POSTGRES_PASSWORD=wowcheckout +POSTGRES_DB=wowcheckout +DATABASE_URL=postgresql://wowcheckout:wowcheckout@db:5432/wowcheckout + +# Optional: Postgres backups (used by the `db-backup` Compose profile) +BACKUP_RETENTION_DAYS=7 + +# API authentication +API_KEYS=dev-key-1 +API_KEY_ENCRYPTION_KEY=replace-with-fernet-key +RATE_PROVIDER=nonlogs +INVOICE_RECONCILE_INTERVAL_SECONDS=30 + +# Founder donation wallet (view-only) +FOUNDER_PAYMENT_ADDRESS= +FOUNDER_VIEW_KEY= +DONATIONS_ENABLED=false + +# Wownero wallet RPC (view-only wallet generation + subaddresses) +MONERO_WALLET_RPC_URLS=http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083 +MONERO_WALLET_RPC_USER= +MONERO_WALLET_RPC_PASSWORD= +MONERO_WALLET_RPC_WALLET_PASSWORD= +MONERO_WALLET_RPC_WALLET_DIR=/wallets + +# Wownero daemon +# - Default: uses a local daemon +# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568 +MONERO_DAEMON_URL=http://192.168.88.235:34568 + +# UI +API_BASE_URL=http://api:8000 +SITE_URL=https://wowcheckout.such.software diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c742db9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +ui/.env + +ui/.next/ +ui/node_modules/ + +**/__pycache__/ +*.py[cod] + +nginx/certs/ + +backups/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c5da7b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 xmrcheckout contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..08514d7 --- /dev/null +++ b/README.md @@ -0,0 +1,320 @@ +

+ XMR Checkout logo +

+ +# wowcheckout + +Non-custodial Wownero checkout software for merchants. Payments go directly from the customer to the merchant wallet. + +wowcheckout is open source and self-hostable. It creates invoices, shows payment instructions, and watches the chain for incoming payments using view-only wallet access (address + secret view key). It can also notify your systems via API and webhooks. + +Hard rules: +- It never requests or stores private spend keys. +- It never signs transactions. +- It never moves funds on behalf of users. +- View-only access (wallet address + private view key) is the maximum trust boundary. + +## Contents + +- [Who it's for](#who-its-for) +- [What it does](#what-it-does) +- [Trust model](#trust-model) +- [Repository layout](#repository-layout) +- [Screenshots](#screenshots) +- [Quick start](#quick-start) +- [Self-hosted deployment](#self-hosted-deployment-docker-compose) +- [Self-hosted deployment (no Docker)](#self-hosted-deployment-no-docker) +- [Development (API)](#development-api-python) + +## Who it's for + +If you want Wownero payments to go straight to your own wallet, this is for you. + +Common fits: +- A merchant who wants a clean hosted checkout UI (self-hosted by you). +- A team that wants API + webhooks to plug into an existing order flow. +- Anyone who prefers a conservative trust model (view-only access for detection). + +## What it does + +Typical flow: +1. Your integration creates an invoice (defined in WOW). +2. The UI shows payment instructions (address + amount). +3. The system observes the chain using view-only wallet access to detect payments and update invoice status. +4. Optional integrations (for example webhooks) can be used to trigger your internal order flow. + +Not included (by design): +- It does not provide custody, refunds, or any fund-moving automation. +- It does not act as a financial intermediary. +- It does not touch fiat rails in the core system. + +## Trust model + +- Funds always move from the customer to the merchant wallet; wowcheckout only observes the chain and reports status. +- You keep spend authority. The maximum permission level wowcheckout uses is view-only wallet access (wallet address + private view key). +- If any configuration or integration implies spend authority, treat it as a misconfiguration and stop. + +## Repository layout + +- `ui/`: web UI +- `api/`: API service (Python) +- `docker-compose.yml`: local stack and self-hosted deployment +- `nginx/`: optional reverse proxy / TLS termination (used by Docker Compose) + +## Screenshots + +Post-login UI: + +### Dashboard + +![Dashboard overview](screenshots/ui_dashboard.png) + +### Invoices + +![Invoice list and status](screenshots/ui_invoices.png) + +### Create invoice + +![Create invoice dialog (WOW amount)](screenshots/create_invoice_xmr.png) + +![Create invoice dialog (fiat-denominated input, informational estimate)](screenshots/create_invoice_fiat.png) + +![Create invoice dialog (summary)](screenshots/create_invoice_summary.png) + +### Public invoice page + +![Public invoice page awaiting on-chain payment](screenshots/public_invoice_pending_payment.png) + +## Quick start + +### Homepage only (Docker) + +``` +docker build -t wowcheckout-home . +docker run --rm -p 8080:80 wowcheckout-home +``` + +Open `http://localhost:8080`. + +### Full stack (Docker Compose) + +``` +docker compose up --build +``` + +Open `http://localhost:8180` for the UI. +In the default Compose configuration, only `nginx` is published to the host. The API, Postgres, and wallet-rpc services are reachable only inside the Docker network. +If you prefer not to run `nginx`, you can publish `ui` and `api` ports directly instead (you will also need to serve `qr/` at `/qr/` on your site URL). + +## Self-hosted deployment (Docker Compose) + +1. Copy the environment template and fill in required values: + +``` +cp .env.example .env +``` + +2. Set required values in `.env`: +- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` +- `API_KEYS`, `API_KEY_ENCRYPTION_KEY` +- `SITE_URL` (public URL for the UI) +- Wownero view-only wallet settings (`MONERO_WALLET_RPC_*`) +- `MONERO_DAEMON_URL` (choose one of the options below) + +3. Choose a Wownero daemon source: +- **Use an existing daemon (default):** + - Leave `MONERO_DAEMON_URL` as-is (the default points at a local Wownero daemon). +- **Run your own daemon via Docker Compose:** + - Set `MONERO_DAEMON_URL=http://wownerod:34568` + - Start the stack with the `local-daemon` profile (see step 5) + - Note: initial sync can take a long time and uses significant disk; payment detection won’t be reliable until the daemon is synced. + +4. Choose a wallet-rpc target and provision view-only wallets: +- Use the bundled wallet-rpc containers: + - Set `MONERO_WALLET_RPC_URLS=http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083` +- Or point to an external wallet-rpc service: + - Set `MONERO_WALLET_RPC_URLS`, `MONERO_WALLET_RPC_USER`, `MONERO_WALLET_RPC_PASSWORD`, and `MONERO_WALLET_RPC_WALLET_PASSWORD` + +5. Start the stack: + +``` +docker compose up --build -d +``` + +If you’re running the bundled `wownerod` service: + +``` +docker compose --profile local-daemon up --build -d +``` + +### Optional: Postgres backups (disabled by default) + +This repository includes an optional `db-backup` service that runs `pg_dump` hourly and writes backups to `./backups/postgres` on the host. + +Enable it by starting Compose with the `db-backup` profile: + +``` +docker compose --profile db-backup up --build -d +``` + +Retention defaults to 7 days. To override: +- Set `BACKUP_RETENTION_DAYS` in `.env` + +### Optional: donations (disabled by default) + +Donation endpoints and UI are off by default for self-hosted deployments. +To enable donations: +- Set `DONATIONS_ENABLED=true` +- Set `FOUNDER_PAYMENT_ADDRESS` and `FOUNDER_VIEW_KEY` + +The UI uses the same flag (via Compose), so `/donate` stays unavailable unless donations are explicitly enabled. + +## Self-hosted deployment (no Docker) + +This section describes running the services directly on a host (or VMs) without Docker. + +You will run: +- Postgres (external service) +- API (`gunicorn` / `uvicorn`) +- Reconciler worker (a separate process) +- UI (Next.js) +- A reverse proxy is optional, but strongly recommended for TLS, serving `/qr/`, and providing a single origin for UI + API. + +### 1. Install prerequisites + +- Postgres 16+ +- Python 3.12+ +- Node.js 20+ (for the UI) +- A Wownero daemon endpoint (`MONERO_DAEMON_URL`) and at least one `wownero-wallet-rpc` instance reachable by the API + +### 2. Configure environment variables + +Start from `.env.example` and adjust for your host. For non-Docker deployments you will typically set: +- `DATABASE_URL=postgresql://...@127.0.0.1:5432/...` +- `QR_STORAGE_DIR` to a persistent directory on disk (for example `/var/lib/wowcheckout/qr`) +- `SITE_URL` to your public URL (for example `https://example.com`) +- `API_BASE_URL` for the UI: + - If using a reverse proxy that routes `/api/` to the API on the same origin, set `API_BASE_URL=https://example.com` + - If not using a reverse proxy, set `API_BASE_URL=http://127.0.0.1:8000` (see the notes in step 6) + +If you need a new `API_KEY_ENCRYPTION_KEY` value: + +``` +python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())' +``` + +### 3. Create the Postgres database + +Create a database and user matching your `DATABASE_URL`. On first startup, the API will create tables automatically. + +### 4. Run the API + +``` +python -m venv api/.venv +api/.venv/bin/pip install -r api/requirements.txt + +# Export env vars (or use your process manager to load them) +set -a +source .env +set +a + +api/.venv/bin/gunicorn app.main:app -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 -w "${GUNICORN_WORKERS:-2}" --chdir api +``` + +### 5. Run the reconciler worker + +Run this as a separate long-running process (same environment variables as the API): + +``` +set -a +source .env +set +a + +api/.venv/bin/python -m app.reconciler +``` + +### 6. Build and run the UI + +``` +cd ui +npm ci + +# Optional but recommended to keep these explicit in non-Docker deployments +export API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:8000}" +export NEXT_PUBLIC_SITE_URL="${SITE_URL:-http://127.0.0.1:3000}" +export NEXT_PUBLIC_DONATIONS_ENABLED="${DONATIONS_ENABLED:-false}" + +npm run build +npm run start -- -p 3000 -H 127.0.0.1 +``` + +Notes: +- The UI makes some in-browser requests to `/api/...` on the same origin. In production, prefer a reverse proxy that serves the UI and forwards `/api/` to the API. +- The QR PNGs are written to `QR_STORAGE_DIR` and must be reachable at `https:///qr/.png`. + +### Optional: reverse proxy (Nginx) + +Nginx is optional. Any reverse proxy that can: +- route `/api/` to the API, +- route `/` to the UI, +- and serve the QR directory at `/qr/` + +is fine. + +Example (adjust `server_name`, TLS, and paths to match your host): + +``` +server { + listen 443 ssl; + server_name example.com; + + # ssl_certificate ...; + # ssl_certificate_key ...; + + location /api/ { + proxy_pass http://127.0.0.1:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /qr/ { + alias /var/lib/wowcheckout/qr/; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + proxy_pass http://127.0.0.1:3000/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Development (API, Python) + +1. Set environment variables (see `api/.env.example`): + +``` +export DATABASE_URL=postgresql://wowcheckout:wowcheckout@localhost:5432/wowcheckout +export API_KEYS=change-me-1 +``` + +2. Install dependencies: + +``` +python -m venv .venv +source .venv/bin/activate +pip install -r api/requirements.txt +``` + +3. Start the API: + +``` +cd api +uvicorn app.main:app --reload +``` + +The API listens on `http://127.0.0.1:8000`. diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..22f7edb --- /dev/null +++ b/api/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://wowcheckout:wowcheckout@localhost:5432/wowcheckout +API_KEYS=change-me-1,change-me-2 +RATE_PROVIDER=nonlogs +INVOICE_RECONCILE_INTERVAL_SECONDS=30 +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..b94e212 --- /dev/null +++ b/api/app/btcpay_routes.py @@ -0,0 +1,704 @@ +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_wow_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 = "WOW-CHAIN" +BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "WOW", "WOW_CHAIN"} +BTCPAY_WEBHOOK_EVENTS = { + "InvoiceCreated", + "InvoiceReceivedPayment", + "InvoicePaidInFull", + "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": "WOW Checkout", + "website": "", + "defaultCurrency": "WOW", + "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": "WOW 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() == "WOW": + return amount, None + try: + quote = get_wow_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), "WOW", {} + + +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": "WOW", + } + ] + + +@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) + dispatch_btcpay_webhooks(db, str(user.id), "InvoiceCreated", 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": "WOW", + "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": "WOW 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..20ff72e --- /dev/null +++ b/api/app/btcpay_webhooks.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import time +from datetime import datetime, timezone +from urllib.parse import urljoin + +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__) + +_REDIRECT_STATUSES = {301, 302, 303, 307, 308} + + +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", + "User-Agent": "wowcheckout-btcpay-webhook/1.0", + } + response = _post_with_redirects( + hook.url, + data=body, + headers=headers, + timeout=5, + ) + if response is None: + continue + if response.status_code >= 400: + logger.warning( + "BTCPay webhook delivered non-success status", + extra={ + "webhook_id": str(hook.id), + "event": event_type, + "http_status": response.status_code, + }, + ) + 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() + + +def _post_with_redirects( + url: str, + *, + data: bytes, + headers: dict[str, str], + timeout: int, + max_redirects: int = 3, +) -> requests.Response | None: + current_url = url + for _ in range(max_redirects + 1): + response = requests.post( + current_url, + data=data, + headers=headers, + timeout=timeout, + allow_redirects=False, + ) + if response.status_code not in _REDIRECT_STATUSES: + return response + location = response.headers.get("Location") + if not location: + return response + current_url = urljoin(current_url, location) + return None diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..b64812f --- /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", "1")) +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")) +RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken") +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..216faed --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,265 @@ +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="wowcheckout.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 1" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ALTER COLUMN default_confirmation_target SET DEFAULT 1" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'wownero'" + ) + ) + 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 'btcpay_classic'" + ) + ) + connection.execute( + text( + "ALTER TABLE users " + "ALTER COLUMN btcpay_checkout_style SET DEFAULT 'btcpay_classic'" + ) + ) + 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 = 'btcpay_classic' " + "WHERE btcpay_checkout_style IS NULL" + ) + ) + connection.execute( + text( + "UPDATE users " + "SET btcpay_checkout_style = 'btcpay_classic' " + "WHERE btcpay_checkout_style = 'standard' " + "AND NOT EXISTS (" + " SELECT 1 FROM profile_history ph " + " WHERE ph.user_id = users.id AND ph.field_name = 'btcpay_checkout_style'" + ")" + ) + ) + connection.execute( + text( + "UPDATE users SET default_confirmation_target = 1 " + "WHERE default_confirmation_target IS NULL" + ) + ) + connection.execute( + text( + "UPDATE users SET default_qr_logo = 'wownero' " + "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..6e1c3d1 --- /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="1") + default_qr_logo = Column(String, nullable=False, server_default="wownero") + default_qr_logo_data_url = Column(String, nullable=True) + btcpay_checkout_style = Column(String, nullable=False, server_default="btcpay_classic") + 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..55e17bc --- /dev/null +++ b/api/app/qr_codes.py @@ -0,0 +1,188 @@ +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) + try: + os.chmod(storage_dir, 0o755) + except Exception: + pass + filename = f"{invoice.id}.png" + path = os.path.join(storage_dir, filename) + if os.path.exists(path): + try: + os.chmod(path, 0o644) + except Exception: + pass + return path + png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings) + _atomic_write(path, png_bytes) + try: + os.chmod(path, 0o644) + except Exception: + pass + 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"wownero:{invoice.address}?{query}" + + +def resolve_qr_settings(invoice: Invoice) -> QrSettings: + metadata: Any = invoice.metadata_json or {} + if not isinstance(metadata, dict): + return QrSettings(logo="wownero", logo_data_url=None) + qr_meta = metadata.get("qr") + if not isinstance(qr_meta, dict): + return QrSettings(logo="wownero", 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 ("wownero", "none", "custom"): + return QrSettings(logo="wownero", 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..86e96f4 --- /dev/null +++ b/api/app/rates.py @@ -0,0 +1,92 @@ +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 + +NONLOGS_MARKETS_URL = "https://api.nonlogs.io/api/markets" +KRAKEN_TICKER_URL = "https://api.kraken.com/0/public/Ticker" +RATE_SOURCE = "nonlogs" +RATE_TTL_SECONDS = 60 + +_cache_lock = threading.Lock() +_cached_quote: QuoteResult | None = None +_cached_at: float | None = None + +_btc_cache_lock = threading.Lock() +_cached_btc_usd: Decimal | None = None +_cached_btc_at: float | None = None +BTC_CACHE_TTL = 30 + + +@dataclass(frozen=True) +class QuoteResult: + rate: Decimal + currency: str + source: str + quoted_at: datetime + + +def _get_btc_usd() -> Decimal: + global _cached_btc_usd, _cached_btc_at + with _btc_cache_lock: + if _cached_btc_usd and _cached_btc_at: + if time.monotonic() - _cached_btc_at < BTC_CACHE_TTL: + return _cached_btc_usd + + resp = requests.get(KRAKEN_TICKER_URL, params={"pair": "XXBTZUSD"}, timeout=5) + resp.raise_for_status() + data = resp.json() + if data.get("error"): + raise RuntimeError(f"Kraken API error: {data['error']}") + price = data["result"]["XXBTZUSD"]["c"][0] + result = Decimal(str(price)) + with _btc_cache_lock: + _cached_btc_usd = result + _cached_btc_at = time.monotonic() + return result + + +def get_wow_rate(currency: str) -> QuoteResult: + global _cached_quote, _cached_at + normalized_currency = currency.strip().lower() + if normalized_currency != "usd": + raise ValueError(f"Unsupported fiat currency: {currency} (supported: usd)") + + with _cache_lock: + if _cached_quote and _cached_at: + if time.monotonic() - _cached_at < RATE_TTL_SECONDS: + return _cached_quote + + headers = { + "Accept": "application/json", + "User-Agent": "wowcheckout/1.0", + } + resp = requests.get(NONLOGS_MARKETS_URL, headers=headers, timeout=10) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + markets = data.get("markets", {}) + + # Use WOW-BTC price converted via Kraken BTC/USD + wow_btc = markets.get("WOW-BTC", {}) + if not wow_btc.get("last_price"): + raise ValueError("Could not get WOW-BTC rate from Nonlogs") + + btc_usd = _get_btc_usd() + rate = Decimal(str(wow_btc["last_price"])) * btc_usd + + quote = QuoteResult( + rate=rate, + currency="USD", + source=RATE_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..b846040 --- /dev/null +++ b/api/app/reconciler.py @@ -0,0 +1,265 @@ +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), "InvoicePaidInFull", invoice) + dispatch_btcpay_webhooks( + db, str(user.id), "InvoiceProcessing", invoice + ) + if max_confirmations >= invoice.confirmation_target and invoice.status != "confirmed": + logger.info( + "Invoice confirmed", + extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, + ) + invoice.status = "confirmed" + if invoice.confirmed_at is None: + invoice.confirmed_at = now + db.add(invoice) + db.commit() + dispatch_webhooks(db, str(user.id), "invoice.confirmed", invoice) + dispatch_btcpay_webhooks(db, str(user.id), "InvoiceSettled", invoice) + dispatch_btcpay_webhooks( + db, str(user.id), "InvoicePaymentSettled", invoice + ) + 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..8bfdbe4 --- /dev/null +++ b/api/app/routes.py @@ -0,0 +1,1308 @@ +import uuid +from typing import Any +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_DOWN, InvalidOperation +import csv +import io +import json + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi.responses import RedirectResponse, 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_wow_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 _get_checkout_continue_url(invoice: Invoice) -> str | None: + metadata = invoice.metadata_json or {} + if not isinstance(metadata, dict): + return None + checkout = metadata.get("checkout") + if not isinstance(checkout, dict): + return None + value = checkout.get("continue_url") + if not isinstance(value, str): + return None + trimmed = value.strip() + return trimmed or 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 + + +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] = {} + if invoice.total_paid_atomic is not None: + total_paid = (Decimal(invoice.total_paid_atomic) / Decimal("1000000000000")).quantize( + Decimal("0.000000000001"), rounding=ROUND_DOWN + ) + update["amount_paid_xmr"] = format_xmr_amount(total_paid) + 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 + checkout = btcpay_data.get("checkout") + if isinstance(checkout, dict): + redirect_url = checkout.get("redirectURL") + if isinstance(redirect_url, str) and redirect_url.strip(): + update["btcpay_redirect_url"] = redirect_url.strip() + redirect_auto = checkout.get("redirectAutomatically") + if isinstance(redirect_auto, bool): + update["btcpay_redirect_automatically"] = redirect_auto + if isinstance(metadata, dict) and isinstance(metadata.get("quote"), dict): + update["quote"] = metadata.get("quote") + if isinstance(metadata, dict) and isinstance(metadata.get("posData"), str): + try: + pos_data = json.loads(metadata.get("posData", "")) + except (TypeError, ValueError, json.JSONDecodeError): + pos_data = None + if isinstance(pos_data, dict): + woo = pos_data.get("WooCommerce") + if isinstance(woo, dict): + order_id = woo.get("Order ID") + order_number = woo.get("Order Number") + if order_id is not None: + update["btcpay_order_id"] = str(order_id) + if order_number is not None: + update["btcpay_order_number"] = str(order_number) + 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 + continue_url = _get_checkout_continue_url(invoice) + update["checkout_continue_available"] = bool( + invoice.status == "confirmed" and continue_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_wow_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/invoice/{invoice_id}/continue", + response_class=RedirectResponse, +) +def continue_invoice_after_confirmation( + invoice_id: uuid.UUID, + 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", + ) + if invoice.status != "confirmed": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Continue link not available", + ) + continue_url = _get_checkout_continue_url(invoice) + if not continue_url: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Continue link not available", + ) + response = RedirectResponse(url=continue_url, status_code=status.HTTP_302_FOUND) + response.headers["Cache-Control"] = "no-store" + return response + + +@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 + if payload.checkout_continue_url is not None: + checkout = metadata.get("checkout") if isinstance(metadata, dict) else None + if not isinstance(checkout, dict): + checkout = {} + checkout = {**checkout, "continue_url": str(payload.checkout_continue_url)} + metadata = {**(metadata if isinstance(metadata, dict) else {}), "checkout": checkout} + 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 + try: + amount_xmr = Decimal(needle) + except (InvalidOperation, ValueError): + amount_xmr = None + matchers = [] + if invoice_uuid is not None: + matchers.append(Invoice.id == invoice_uuid) + if amount_xmr is not None: + matchers.append(Invoice.amount_xmr == amount_xmr) + 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 + try: + amount_xmr = Decimal(needle) + except (InvalidOperation, ValueError): + amount_xmr = None + matchers = [] + if invoice_uuid is not None: + matchers.append(Invoice.id == invoice_uuid) + if amount_xmr is not None: + matchers.append(Invoice.amount_xmr == amount_xmr) + 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..7ecfb67 --- /dev/null +++ b/api/app/schemas.py @@ -0,0 +1,279 @@ +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["wownero", "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) = 1 + checkout_continue_url: AnyUrl | None = Field( + default=None, + description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.", + ) + metadata: dict[str, Any] | None = None + expires_at: datetime | None = Field( + default=None, + description="Defaults to 60 minutes 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 + + @field_validator("checkout_continue_url") + @classmethod + def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None: + if value is None: + return None + if value.scheme == "https": + return value + if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"): + return value + raise ValueError("checkout_continue_url must use https (http is allowed for localhost)") + + +class DonationCreate(BaseModel): + amount_xmr: Decimal = Field(gt=0) + confirmation_target: conint(ge=0, le=10) = 1 + + +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) = 1 + checkout_continue_url: AnyUrl | None = Field( + default=None, + description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.", + ) + metadata: dict[str, Any] | None = None + expires_at: datetime | None = Field( + default=None, + description="Defaults to 60 minutes 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 + + @field_validator("checkout_continue_url") + @classmethod + def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None: + if value is None: + return None + if value.scheme == "https": + return value + if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"): + return value + raise ValueError("checkout_continue_url must use https (http is allowed for localhost)") + + +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 + amount_paid_xmr: str | None = None + 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 + btcpay_redirect_url: str | None = None + btcpay_redirect_automatically: bool | None = None + btcpay_order_id: str | None = None + btcpay_order_number: str | None = None + checkout_continue_available: bool = False + 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) = 1 + default_qr_logo: QrLogoMode = "wownero" + default_qr_logo_data_url: str | None = None + btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic" + 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..d2efb6c --- /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"wowcheckout_{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..a53fc69 --- /dev/null +++ b/api/app/subaddress_derivation.py @@ -0,0 +1,41 @@ +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 + +# Override monero library network constants for Wownero +from monero import const as _mc +_mc.NETS = ('main',) +_mc.MASTERADDR_NETBYTES = (53,) # Wownero mainnet address prefix +_mc.SUBADDR_NETBYTES = (63,) # Wownero subaddress prefix +_mc.INTADDRR_NETBYTES = (54,) # Wownero integrated address prefix + + +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73e0a9d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,225 @@ +services: + nginx: + build: + context: ./nginx + ports: + - "8180:80" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/certs:/etc/nginx/certs + - ./qr:/qr:ro + depends_on: + ui: + condition: service_started + api: + condition: service_started + restart: unless-stopped + api: + build: + context: ./api + environment: + DATABASE_URL: ${DATABASE_URL} + API_KEYS: ${API_KEYS} + API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY} + RATE_PROVIDER: ${RATE_PROVIDER:-kraken} + FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS} + FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY} + DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false} + QR_STORAGE_DIR: ${QR_STORAGE_DIR:-/qr} + MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083} + MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER} + MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD} + MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD} + MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets} + MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568} + GUNICORN_WORKERS: ${GUNICORN_WORKERS:-5} + volumes: + - ./qr:/qr + depends_on: + db: + condition: service_healthy + restart: unless-stopped + reconciler: + build: + context: ./api + command: ["python", "-m", "app.reconciler"] + environment: + DATABASE_URL: ${DATABASE_URL} + API_KEYS: ${API_KEYS} + API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY} + INVOICE_RECONCILE_INTERVAL_SECONDS: ${INVOICE_RECONCILE_INTERVAL_SECONDS} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083} + MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER} + MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD} + MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD} + MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets} + MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568} + depends_on: + api: + condition: service_started + db: + condition: service_healthy + restart: unless-stopped + wownerod: + build: + context: ./docker/monero + args: + MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0} + image: wownero-local:${MONERO_VERSION:-0.11.3.0} + entrypoint: ["wownerod"] + command: + [ + "--non-interactive", + "--rpc-bind-ip", + "0.0.0.0", + "--rpc-bind-port", + "34568", + "--confirm-external-bind", + "--prune-blockchain", + "--data-dir", + "/data", + "--log-level", + "0" + ] + volumes: + - wownerod_data:/data + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:34568/get_height >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 10 + start_period: 60s + restart: unless-stopped + profiles: ["local-daemon"] + wallet-rpc-reconciler-1: + build: + context: ./docker/monero + args: + MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0} + image: wownero-local:${MONERO_VERSION:-0.11.3.0} + command: + [ + "wownero-wallet-rpc", + "--non-interactive", + "--rpc-bind-ip", + "0.0.0.0", + "--rpc-bind-port", + "18083", + "--confirm-external-bind", + "--daemon-address", + "${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", + "--rpc-login", + "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", + "--wallet-dir", + "${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}", + "--password", + "${MONERO_WALLET_RPC_WALLET_PASSWORD}", + "--log-level", + "3" + ] + volumes: + - ./wallets/reconciler:/wallets + restart: unless-stopped + wallet-rpc-reconciler-2: + build: + context: ./docker/monero + args: + MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0} + image: wownero-local:${MONERO_VERSION:-0.11.3.0} + command: + [ + "wownero-wallet-rpc", + "--non-interactive", + "--rpc-bind-ip", + "0.0.0.0", + "--rpc-bind-port", + "18083", + "--confirm-external-bind", + "--daemon-address", + "${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", + "--rpc-login", + "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", + "--wallet-dir", + "${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}", + "--password", + "${MONERO_WALLET_RPC_WALLET_PASSWORD}", + "--log-level", + "3" + ] + volumes: + - ./wallets/reconciler:/wallets + restart: unless-stopped + wallet-rpc-reconciler-3: + build: + context: ./docker/monero + args: + MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0} + image: wownero-local:${MONERO_VERSION:-0.11.3.0} + command: + [ + "wownero-wallet-rpc", + "--non-interactive", + "--rpc-bind-ip", + "0.0.0.0", + "--rpc-bind-port", + "18083", + "--confirm-external-bind", + "--daemon-address", + "${MONERO_DAEMON_URL:-http://192.168.88.235:34568}", + "--rpc-login", + "${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}", + "--wallet-dir", + "${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}", + "--password", + "${MONERO_WALLET_RPC_WALLET_PASSWORD}", + "--log-level", + "3" + ] + volumes: + - ./wallets/reconciler:/wallets + restart: unless-stopped + ui: + build: + context: ./ui + environment: + API_BASE_URL: ${API_BASE_URL:-http://nginx/api} + NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost} + NEXT_PUBLIC_DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false} + depends_on: + api: + condition: service_started + restart: unless-stopped + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 10 + db-backup: + build: + context: ./docker/pg_backup + environment: + POSTGRES_HOST: db + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7} + volumes: + - ./backups/postgres:/backups + depends_on: + db: + condition: service_healthy + restart: unless-stopped + profiles: ["db-backup"] +volumes: + postgres_data: + wownerod_data: diff --git a/docker/monero/Dockerfile b/docker/monero/Dockerfile new file mode 100644 index 0000000..fd3d8db --- /dev/null +++ b/docker/monero/Dockerfile @@ -0,0 +1,22 @@ +FROM debian:bookworm-slim + +ARG MONERO_VERSION=0.11.3.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl bzip2 \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + curl -fsSL -o /tmp/wownero.tar.bz2 \ + "https://codeberg.org/wownero/wownero/releases/download/v${MONERO_VERSION}/wownero-x86_64-linux-gnu-v${MONERO_VERSION}.tar.bz2"; \ + tar -xjf /tmp/wownero.tar.bz2 -C /tmp; \ + cp /tmp/wownero-*/wownerod /usr/local/bin/; \ + cp /tmp/wownero-*/wownero-wallet-rpc /usr/local/bin/; \ + rm -rf /tmp/wownero* /tmp/wownero.tar.bz2 + +COPY entrypoint.sh /usr/local/bin/monero-wallet-rpc-entrypoint +RUN chmod +x /usr/local/bin/monero-wallet-rpc-entrypoint + +EXPOSE 34568 18083 + +ENTRYPOINT ["/usr/local/bin/monero-wallet-rpc-entrypoint"] diff --git a/docker/monero/entrypoint.sh b/docker/monero/entrypoint.sh new file mode 100644 index 0000000..f55069b --- /dev/null +++ b/docker/monero/entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -e + +normalize_address() { + value="$1" + if [ -z "$value" ]; then + return + fi + case "$value" in + http://*) + value="${value#http://}" + ;; + https://*) + value="${value#https://}" + ;; + esac + value="${value%%/*}" + printf '%s' "$value" +} + +args="" +while [ "$#" -gt 0 ]; do + case "$1" in + --daemon-address) + shift + normalized="$(normalize_address "$1")" + args="$args --daemon-address $normalized" + ;; + --daemon-address=*) + value="${1#--daemon-address=}" + normalized="$(normalize_address "$value")" + args="$args --daemon-address=$normalized" + ;; + *) + args="$args $1" + ;; + esac + shift +done + +# shellcheck disable=SC2086 +exec $args diff --git a/docker/pg_backup/Dockerfile b/docker/pg_backup/Dockerfile new file mode 100644 index 0000000..b011fb1 --- /dev/null +++ b/docker/pg_backup/Dockerfile @@ -0,0 +1,12 @@ +FROM postgres:16-alpine + +RUN apk add --no-cache bash + +COPY backup.sh /usr/local/bin/backup.sh +COPY pg_backup.cron /etc/cron.d/pg_backup + +RUN chmod +x /usr/local/bin/backup.sh \ + && chmod 0644 /etc/cron.d/pg_backup \ + && crontab /etc/cron.d/pg_backup + +CMD ["crond", "-f", "-l", "8"] diff --git a/docker/pg_backup/backup.sh b/docker/pg_backup/backup.sh new file mode 100644 index 0000000..84222e9 --- /dev/null +++ b/docker/pg_backup/backup.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${POSTGRES_HOST:?}" +: "${POSTGRES_USER:?}" +: "${POSTGRES_PASSWORD:?}" +: "${POSTGRES_DB:?}" + +backup_retention_days="${BACKUP_RETENTION_DAYS:-7}" + +export PGPASSWORD="$POSTGRES_PASSWORD" + +backup_dir="/backups" +timestamp="$(date +%Y%m%d_%H%M%S)" +backup_path="${backup_dir}/${POSTGRES_DB}_${timestamp}.dump" + +mkdir -p "$backup_dir" + +pg_dump -h "$POSTGRES_HOST" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -F c -f "$backup_path" + +find "$backup_dir" -type f -name "*.dump" -mtime "+${backup_retention_days}" -delete diff --git a/docker/pg_backup/pg_backup.cron b/docker/pg_backup/pg_backup.cron new file mode 100644 index 0000000..2e88156 --- /dev/null +++ b/docker/pg_backup/pg_backup.cron @@ -0,0 +1 @@ +0 * * * * /usr/local/bin/backup.sh >> /var/log/pg_backup.log 2>&1 diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..80e3dec --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:1.27-alpine + +RUN apk add --no-cache openssl + +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..8e66427 --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,46 @@ +limit_req_zone $binary_remote_addr zone=global_rate_limit:10m rate=50r/s; + +server { + listen 80; + server_name wowcheckout.such.software localhost; + + limit_req zone=global_rate_limit burst=200 nodelay; + + if ($request_uri ~* "//+") { + return 308 $scheme://$host$uri$is_args$args; + } + + location /api/ { + proxy_pass http://api:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api { + return 301 /api/; + } + + location = /openapi.json { + proxy_pass http://api:8000/openapi.json; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /qr/ { + alias /qr/; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + proxy_pass http://ui:3000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/nginx/docker-entrypoint.sh b/nginx/docker-entrypoint.sh new file mode 100644 index 0000000..124af83 --- /dev/null +++ b/nginx/docker-entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +cert_dir="/etc/nginx/certs" +cert_path="${cert_dir}/dev.crt" +key_path="${cert_dir}/dev.key" + +if [ ! -f "$cert_path" ] || [ ! -f "$key_path" ]; then + mkdir -p "$cert_dir" + rm -f "$cert_path" "$key_path" + openssl req \ + -x509 \ + -nodes \ + -newkey rsa:2048 \ + -days 365 \ + -keyout "$key_path" \ + -out "$cert_path" \ + -subj "/CN=localhost" +fi + +exec "$@" diff --git a/screenshots/create_invoice_fiat.png b/screenshots/create_invoice_fiat.png new file mode 100644 index 0000000..e774c02 Binary files /dev/null and b/screenshots/create_invoice_fiat.png differ diff --git a/screenshots/create_invoice_summary.png b/screenshots/create_invoice_summary.png new file mode 100644 index 0000000..a7f5e54 Binary files /dev/null and b/screenshots/create_invoice_summary.png differ diff --git a/screenshots/create_invoice_xmr.png b/screenshots/create_invoice_xmr.png new file mode 100644 index 0000000..6ad2920 Binary files /dev/null and b/screenshots/create_invoice_xmr.png differ diff --git a/screenshots/public_invoice_pending_payment.png b/screenshots/public_invoice_pending_payment.png new file mode 100644 index 0000000..c4da7eb Binary files /dev/null and b/screenshots/public_invoice_pending_payment.png differ diff --git a/screenshots/ui_dashboard.png b/screenshots/ui_dashboard.png new file mode 100644 index 0000000..3650f1e Binary files /dev/null and b/screenshots/ui_dashboard.png differ diff --git a/screenshots/ui_invoices.png b/screenshots/ui_invoices.png new file mode 100644 index 0000000..2aaa42d Binary files /dev/null and b/screenshots/ui_invoices.png differ diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 0000000..4f35e97 --- /dev/null +++ b/ui/.env.example @@ -0,0 +1,4 @@ +API_BASE_URL=http://localhost:8000 +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +NEXT_PUBLIC_SITE_URL=http://localhost +NEXT_PUBLIC_DONATIONS_ENABLED=false diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs new file mode 100644 index 0000000..76e1389 --- /dev/null +++ b/ui/.eslintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ["next/core-web-vitals"], +}; diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..089bb08 --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/ui/app/(app)/dashboard/actions.ts b/ui/app/(app)/dashboard/actions.ts new file mode 100644 index 0000000..aff08a6 --- /dev/null +++ b/ui/app/(app)/dashboard/actions.ts @@ -0,0 +1,812 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000"; + +export type InvoiceState = { + error: string | null; + invoiceId: string | null; + address: string | null; + amount: string | null; + recipientName: string | null; + description: string | null; + subaddressIndex: number | null; + warnings: string[] | null; +}; + +export type ApiKeyState = { + apiKey: string; + error: string | null; +}; + +export type WebhookSecretState = { + webhookSecret: string | null; + error: string | null; +}; + +export type WebhookFormState = { + error: string | null; + success: string | null; +}; + +export type ArchiveInvoiceState = { + error: string | null; + success: string | null; + archivedId: string | null; +}; + +export type BtcpayCheckoutPreferenceState = { + style: "standard" | "btcpay_classic"; + error: string | null; + success: string | null; +}; + +export type DefaultConfirmationTargetState = { + value: number; + error: string | null; + success: string | null; +}; + +export type DefaultQrLogoState = { + logo: "monero" | "none" | "custom"; + logoDataUrl: string | null; + error: string | null; + success: string | null; +}; + +export type WebhookRedeliverState = { + error: string | null; + success: string | null; +}; + +const tourNoopMessage = "Tour mode only: no changes are saved."; + +export async function createInvoiceTourAction( + _prevState: InvoiceState, + _formData: FormData +): Promise { + return { + error: tourNoopMessage, + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; +} + +export async function archiveInvoiceTourAction( + _prevState: ArchiveInvoiceState, + formData: FormData +): Promise { + const archivedId = String(formData.get("invoice_id") ?? "").trim() || null; + return { + error: null, + success: tourNoopMessage, + archivedId, + }; +} + +export async function createWebhookTourAction( + _prevState: WebhookFormState, + _formData: FormData +): Promise { + return { error: null, success: tourNoopMessage }; +} + +export async function deleteWebhookTourAction( + _prevState: WebhookFormState, + _formData: FormData +): Promise { + return { error: null, success: tourNoopMessage }; +} + +export async function redeliverWebhookDeliveryTourAction( + _prevState: WebhookRedeliverState, + _formData: FormData +): Promise { + return { error: null, success: tourNoopMessage }; +} + +const webhookEventKeys = [ + { event: "invoice.created", key: "invoice_created" }, + { event: "invoice.payment_detected", key: "invoice_payment_detected" }, + { event: "invoice.confirmed", key: "invoice_confirmed" }, + { event: "invoice.expired", key: "invoice_expired" }, +] as const; + +export async function createInvoiceAction( + _prevState: InvoiceState, + formData: FormData +): Promise { + const amountMode = String(formData.get("amount_mode") ?? "xmr").trim(); + const isFiat = amountMode === "fiat"; + const instantConfirmation = formData.get("instant_confirmation") === "1"; + const confirmationRaw = String(formData.get("confirmation_target") ?? "").trim(); + let confirmationTarget: number | null = null; + if (instantConfirmation) { + confirmationTarget = 0; + } else if (confirmationRaw) { + const parsed = Number(confirmationRaw); + if (Number.isNaN(parsed) || parsed < 0 || parsed > 10) { + return { + error: "Confirmation target must be between 0 and 10.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + confirmationTarget = parsed; + } + const amountRaw = String( + formData.get(isFiat ? "amount_fiat" : "amount_xmr") ?? "" + ).trim(); + const amount = Number(amountRaw); + if (Number.isNaN(amount) || amount <= 0) { + return { + error: isFiat ? "Enter a valid fiat amount." : "Enter a valid WOW amount.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + const currency = String(formData.get("currency") ?? "").trim().toUpperCase(); + if (isFiat && !currency) { + return { + error: "Currency is required when using a fiat amount.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + const recipientName = String(formData.get("recipient_name") ?? "").trim(); + const description = String(formData.get("description") ?? "").trim(); + const checkoutContinueUrlRaw = String(formData.get("checkout_continue_url") ?? "").trim(); + if (checkoutContinueUrlRaw) { + try { + const parsed = new URL(checkoutContinueUrlRaw); + const isLocalhost = + parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) { + return { + error: "Continue URL must use https (http is allowed for localhost).", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + } catch { + return { + error: "Continue URL must be a valid URL.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + } + const expiresAtRaw = String(formData.get("expires_at") ?? "").trim(); + const expiresDate = String(formData.get("expires_date") ?? "").trim(); + const expiresTime = String(formData.get("expires_time") ?? "").trim(); + let expiresAt: string | null = null; + if (!expiresAtRaw && expiresTime && !expiresDate) { + return { + error: "Expiry date must include a date.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + if (!expiresAtRaw && expiresDate) { + const timeValue = expiresTime || "00:00"; + expiresAt = `${expiresDate}T${timeValue}`; + } else if (expiresAtRaw) { + const normalized = + expiresAtRaw.length === 10 || expiresAtRaw.endsWith("T") + ? `${expiresAtRaw.replace(/T$/, "")}T00:00` + : expiresAtRaw; + const parsed = new Date(normalized); + if (Number.isNaN(parsed.getTime())) { + return { + error: "Expiry date must be a valid date and time.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + expiresAt = parsed.toISOString(); + } + + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { + error: "API key not found. Please sign in again.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + + const metadata: Record = {}; + if (recipientName) { + metadata.recipient_name = recipientName; + } + if (description) { + metadata.description = description; + } + const qrLogoModeRaw = String(formData.get("qr_logo_mode") ?? "").trim(); + const qrLogoDataUrl = String(formData.get("qr_logo_data_url") ?? "").trim(); + const qrLogoMode = + qrLogoModeRaw === "account_default" || + qrLogoModeRaw === "monero" || + qrLogoModeRaw === "none" || + qrLogoModeRaw === "custom" + ? qrLogoModeRaw + : "account_default"; + const qrPayload = + qrLogoMode === "account_default" + ? null + : qrLogoMode === "custom" + ? qrLogoDataUrl + ? { logo: "custom", logo_data_url: qrLogoDataUrl } + : null + : { logo: qrLogoMode }; + + const response = await fetch(`${apiBaseUrl}/api/core/invoices`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `ApiKey ${apiKey}`, + }, + body: JSON.stringify({ + amount_xmr: isFiat ? null : amountRaw, + amount_fiat: isFiat ? amountRaw : null, + currency: isFiat ? currency : null, + ...(confirmationTarget !== null ? { confirmation_target: confirmationTarget } : {}), + expires_at: expiresAt, + checkout_continue_url: checkoutContinueUrlRaw || null, + metadata: + Object.keys(metadata).length > 0 || qrPayload + ? { + ...(Object.keys(metadata).length > 0 ? metadata : {}), + ...(qrPayload ? { qr: qrPayload } : {}), + } + : null, + }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to create invoice."; + return { + error: message, + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + + const data = (await response.json()) as { + id: string; + address: string; + subaddress_index?: number | null; + amount_xmr: string; + warnings?: string[] | null; + }; + return { + error: null, + invoiceId: data.id, + address: data.address, + amount: data.amount_xmr, + recipientName: recipientName || null, + description: description || null, + subaddressIndex: data.subaddress_index ?? null, + warnings: data.warnings ?? null, + }; +} + +export async function resetApiKeyAction( + _prevState: ApiKeyState +): Promise { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { apiKey: _prevState.apiKey, error: "API key not found. Please sign in again." }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, { + method: "POST", + headers: { + Authorization: `ApiKey ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ reset_api_key: true }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to reset API key."; + return { apiKey: _prevState.apiKey, error: message }; + } + + const data = (await response.json()) as { api_key?: string | null }; + if (!data.api_key) { + return { apiKey: _prevState.apiKey, error: "API key reset did not return a key." }; + } + + // Update the cookie with the new API key + const cookieStore = await cookies(); + cookieStore.set("wowcheckout_api_key", data.api_key, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + + return { apiKey: data.api_key, error: null }; +} + +export async function resetWebhookSecretAction( + _prevState: WebhookSecretState +): Promise { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { + webhookSecret: _prevState.webhookSecret, + error: "API key not found. Please sign in again.", + }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, { + method: "POST", + headers: { + Authorization: `ApiKey ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ reset_webhook_secret: true }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to reset webhook secret."; + return { webhookSecret: _prevState.webhookSecret, error: message }; + } + + const data = (await response.json()) as { webhook_secret?: string | null }; + if (!data.webhook_secret) { + return { + webhookSecret: _prevState.webhookSecret, + error: "Webhook secret reset did not return a secret.", + }; + } + + const cookieStore = await cookies(); + cookieStore.set("wowcheckout_webhook_secret", data.webhook_secret, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + + return { webhookSecret: data.webhook_secret, error: null }; +} + +export async function updateBtcpayCheckoutStyleAction( + _prevState: BtcpayCheckoutPreferenceState, + formData: FormData +): Promise { + const nextStyle = String(formData.get("btcpay_checkout_style") ?? "").trim(); + if (nextStyle !== "standard" && nextStyle !== "btcpay_classic") { + return { + style: _prevState.style, + error: "Select a valid BTCPay checkout layout.", + success: null, + }; + } + + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { + style: _prevState.style, + error: "API key not found. Please sign in again.", + success: null, + }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/profile`, { + method: "PATCH", + headers: { + Authorization: `ApiKey ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ btcpay_checkout_style: nextStyle }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to update BTCPay preference."; + return { style: _prevState.style, error: message, success: null }; + } + + const data = (await response.json()) as { btcpay_checkout_style?: string | null }; + const resolvedStyle = + data.btcpay_checkout_style === "btcpay_classic" || + data.btcpay_checkout_style === "standard" + ? data.btcpay_checkout_style + : nextStyle; + revalidatePath("/dashboard"); + return { + style: resolvedStyle as "standard" | "btcpay_classic", + error: null, + success: "Preference saved.", + }; +} + +export async function updateDefaultConfirmationTargetAction( + _prevState: DefaultConfirmationTargetState, + formData: FormData +): Promise { + const valueRaw = String(formData.get("default_confirmation_target") ?? "").trim(); + const parsed = Number(valueRaw); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 10) { + return { + value: _prevState.value, + error: "Default confirmation target must be an integer between 0 and 10.", + success: null, + }; + } + + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { + value: _prevState.value, + error: "API key not found. Please sign in again.", + success: null, + }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/profile`, { + method: "PATCH", + headers: { + Authorization: `ApiKey ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ default_confirmation_target: parsed }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to update the default confirmation target."; + return { value: _prevState.value, error: message, success: null }; + } + + const data = (await response.json()) as { default_confirmation_target?: number | null }; + const resolvedValue = + typeof data.default_confirmation_target === "number" + ? data.default_confirmation_target + : parsed; + revalidatePath("/dashboard"); + return { value: resolvedValue, error: null, success: "Default updated." }; +} + +export async function updateDefaultQrLogoAction( + _prevState: DefaultQrLogoState, + formData: FormData +): Promise { + const logo = String(formData.get("default_qr_logo") ?? "").trim(); + const logoDataUrlRaw = String(formData.get("default_qr_logo_data_url") ?? "").trim(); + const normalizedLogo = + logo === "none" || logo === "custom" || logo === "monero" ? logo : null; + if (!normalizedLogo) { + return { + logo: _prevState.logo, + logoDataUrl: _prevState.logoDataUrl, + error: "Select a valid QR logo mode.", + success: null, + }; + } + + const logoDataUrl = + normalizedLogo === "custom" ? (logoDataUrlRaw ? logoDataUrlRaw : null) : null; + if (normalizedLogo === "custom" && !logoDataUrl) { + return { + logo: _prevState.logo, + logoDataUrl: _prevState.logoDataUrl, + error: "Upload a logo image for custom mode.", + success: null, + }; + } + + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { + logo: _prevState.logo, + logoDataUrl: _prevState.logoDataUrl, + error: "API key not found. Please sign in again.", + success: null, + }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/profile`, { + method: "PATCH", + headers: { + Authorization: `ApiKey ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + default_qr_logo: normalizedLogo, + default_qr_logo_data_url: logoDataUrl, + }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to update QR settings."; + return { + logo: _prevState.logo, + logoDataUrl: _prevState.logoDataUrl, + error: message, + success: null, + }; + } + + const data = (await response.json()) as { + default_qr_logo?: string | null; + default_qr_logo_data_url?: string | null; + }; + const resolvedLogo = + data.default_qr_logo === "none" || + data.default_qr_logo === "custom" || + data.default_qr_logo === "monero" + ? data.default_qr_logo + : normalizedLogo; + const resolvedDataUrl = + resolvedLogo === "custom" ? data.default_qr_logo_data_url ?? logoDataUrl : null; + revalidatePath("/dashboard"); + return { + logo: resolvedLogo as "monero" | "none" | "custom", + logoDataUrl: resolvedDataUrl ?? null, + error: null, + success: "QR default saved.", + }; +} + +export async function createWebhookAction( + _prevState: WebhookFormState, + formData: FormData +): Promise { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { error: "API key not found. Please sign in again.", success: null }; + } + + const defaultUrl = String(formData.get("default_url") ?? "").trim(); + const events: string[] = []; + const eventUrls: Record = {}; + + webhookEventKeys.forEach(({ event, key }) => { + const enabled = formData.get(`event_${key}`) === "on"; + const override = String(formData.get(`event_url_${key}`) ?? "").trim(); + if (enabled || override) { + events.push(event); + } + if (override) { + eventUrls[event] = override; + } + }); + + if (events.length === 0) { + return { error: "Select at least one invoice event.", success: null }; + } + + if (!defaultUrl) { + const missing = events.filter((event) => !eventUrls[event]); + if (missing.length > 0) { + return { + error: "Provide a default URL or an override URL for each selected event.", + success: null, + }; + } + } + + const payload: { + url?: string; + events?: string[]; + event_urls?: Record; + } = { + events, + }; + + if (defaultUrl) { + payload.url = defaultUrl; + } + + if (Object.keys(eventUrls).length > 0) { + payload.event_urls = eventUrls; + } + + const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `ApiKey ${apiKey}`, + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to create webhook."; + return { error: message, success: null }; + } + + revalidatePath("/dashboard"); + return { error: null, success: "Webhook saved." }; +} + +export async function deleteWebhookAction( + _prevState: WebhookFormState, + formData: FormData +): Promise { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { error: "API key not found. Please sign in again.", success: null }; + } + + const webhookId = String(formData.get("webhook_id") ?? "").trim(); + if (!webhookId) { + return { error: "Webhook id missing.", success: null }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/webhooks/${webhookId}`, { + method: "DELETE", + headers: { + Authorization: `ApiKey ${apiKey}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to remove webhook."; + return { error: message, success: null }; + } + + revalidatePath("/dashboard"); + return { error: null, success: "Webhook removed." }; +} + +export async function archiveInvoiceAction( + _prevState: ArchiveInvoiceState, + formData: FormData +): Promise { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { + error: "API key not found. Please sign in again.", + success: null, + archivedId: null, + }; + } + + const invoiceId = String(formData.get("invoice_id") ?? "").trim(); + if (!invoiceId) { + return { error: "Invoice id missing.", success: null, archivedId: null }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/invoices/${invoiceId}`, { + method: "DELETE", + headers: { + Authorization: `ApiKey ${apiKey}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to archive invoice."; + return { error: message, success: null, archivedId: null }; + } + + revalidatePath("/dashboard"); + return { error: null, success: "Invoice archived until expiry.", archivedId: invoiceId }; +} + +export async function logoutAction() { + const cookieStore = await cookies(); + cookieStore.set("wowcheckout_api_key", "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + cookieStore.set("wowcheckout_webhook_secret", "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + + redirect("/"); +} + +export async function redeliverWebhookDeliveryAction( + _prevState: WebhookRedeliverState, + formData: FormData +): Promise { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return { error: "API key not found. Please sign in again.", success: null }; + } + + const deliveryId = String(formData.get("delivery_id") ?? "").trim(); + if (!deliveryId) { + return { error: "Delivery id missing.", success: null }; + } + + const response = await fetch( + `${apiBaseUrl}/api/core/webhooks/deliveries/${encodeURIComponent(deliveryId)}/redeliver`, + { + method: "POST", + headers: { Authorization: `ApiKey ${apiKey}` }, + cache: "no-store", + } + ); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to redeliver the webhook."; + return { error: message, success: null }; + } + + revalidatePath("/dashboard"); + return { error: null, success: "Redelivery queued." }; +} diff --git a/ui/app/(app)/dashboard/invoices.csv/route.ts b/ui/app/(app)/dashboard/invoices.csv/route.ts new file mode 100644 index 0000000..826e2dc --- /dev/null +++ b/ui/app/(app)/dashboard/invoices.csv/route.ts @@ -0,0 +1,37 @@ +import { cookies } from "next/headers"; + +const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000"; + +export async function GET(request: Request) { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + if (!apiKey) { + return new Response("Unauthorized", { status: 401 }); + } + + const sourceUrl = new URL(request.url); + const targetUrl = new URL(`${apiBaseUrl}/api/core/invoices/export.csv`); + sourceUrl.searchParams.forEach((value, key) => { + targetUrl.searchParams.set(key, value); + }); + + const response = await fetch(targetUrl.toString(), { + headers: { Authorization: `ApiKey ${apiKey}` }, + cache: "no-store", + }); + + if (!response.ok) { + const message = await response.text().catch(() => "Unable to export invoices."); + return new Response(message, { status: response.status }); + } + + const headers = new Headers(); + headers.set("Content-Type", response.headers.get("content-type") ?? "text/csv"); + const disposition = response.headers.get("content-disposition"); + headers.set( + "Content-Disposition", + disposition ?? 'attachment; filename="invoices.csv"' + ); + + return new Response(response.body, { status: 200, headers }); +} + diff --git a/ui/app/(app)/dashboard/page.tsx b/ui/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..c15f764 --- /dev/null +++ b/ui/app/(app)/dashboard/page.tsx @@ -0,0 +1,481 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import ApiKeySection from "../../../components/api-key-section"; +import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-style-section"; +import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section"; +import DefaultQrLogoSection from "../../../components/default-qr-logo-section"; +import InvoicePanel from "../../../components/invoice-panel"; +import WebhookHistoryPanel from "../../../components/webhook-history-panel"; +import WebhookSecretSection from "../../../components/webhook-secret-section"; +import WebhookSection from "../../../components/webhook-section"; + +export const metadata: Metadata = { + title: "Dashboard", +}; + +const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000"; +const tabs = ["overview", "invoices", "webhooks", "profile"] as const; +const webhookTabs = ["settings", "history"] as const; +type DashboardTab = (typeof tabs)[number]; +type WebhookTab = (typeof webhookTabs)[number]; + +type DashboardSearchParams = Record; +type InvoiceSummary = { + id: string; + address: string; + subaddress_index?: number | null; + amount_xmr: string; + status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid"; + confirmation_target: number; + paid_after_expiry?: boolean; + paid_after_expiry_at?: string | null; + created_at: string; + archived_at: string | null; + detected_at: string | null; + confirmed_at: string | null; + expires_at: string | null; +}; +type WebhookSummary = { + id: string; + url: string; + events: string[]; + event_urls?: Record | null; + active: boolean; + created_at: string; +}; +type WebhookDeliverySummary = { + id: string; + webhook_id: string | null; + event: string; + url: string; + invoice_id: string | null; + invoice_address: string | null; + invoice_subaddress_index: number | null; + invoice_amount_xmr: string | null; + invoice_status: + | "pending" + | "payment_detected" + | "confirmed" + | "expired" + | "invalid" + | null; + http_status: number | null; + error_message: string | null; + created_at: string; +}; +type ProfileSummary = { + id: string; + payment_address: string; + default_confirmation_target: number; + default_qr_logo: "monero" | "none" | "custom"; + default_qr_logo_data_url: string | null; + btcpay_checkout_style?: "standard" | "btcpay_classic"; + created_at: string; +}; +export default async function DashboardPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const apiKey = (await cookies()).get("wowcheckout_api_key")?.value; + const webhookSecret = (await cookies()).get("wowcheckout_webhook_secret")?.value ?? null; + if (!apiKey) { + redirect("/?login=1"); + } + + const resolvedSearchParams = await searchParams; + const tabParam = resolvedSearchParams?.tab; + const activeTabValue = Array.isArray(tabParam) ? tabParam[0] : tabParam; + const activeTab: DashboardTab = tabs.includes(activeTabValue as DashboardTab) + ? (activeTabValue as DashboardTab) + : "overview"; + const webhookTabParam = resolvedSearchParams?.webhook_tab; + const activeWebhookTabValue = Array.isArray(webhookTabParam) + ? webhookTabParam[0] + : webhookTabParam; + const activeWebhookTab: WebhookTab = webhookTabs.includes( + activeWebhookTabValue as WebhookTab + ) + ? (activeWebhookTabValue as WebhookTab) + : "settings"; + const includeArchived = + activeTab === "invoices" && + (resolvedSearchParams?.archived === "1" || + resolvedSearchParams?.archived === "true"); + const qParam = resolvedSearchParams?.q; + const queryValue = Array.isArray(qParam) ? qParam[0] : qParam; + const invoiceSearchQuery = activeTab === "invoices" ? (queryValue ?? "") : ""; + const sortParam = resolvedSearchParams?.sort; + const sortValue = Array.isArray(sortParam) ? sortParam[0] : sortParam; + const invoiceSort = + activeTab === "invoices" && typeof sortValue === "string" && sortValue + ? sortValue + : "created_at"; + const orderParam = resolvedSearchParams?.order; + const orderValue = Array.isArray(orderParam) ? orderParam[0] : orderParam; + const invoiceOrder = + activeTab === "invoices" && typeof orderValue === "string" && orderValue + ? orderValue + : "desc"; + + // API key is already available from the cookie + const apiKeyData = activeTab === "profile" ? { api_key: apiKey } : null; + let webhooksData: WebhookSummary[] = []; + let webhookHistory: WebhookDeliverySummary[] = []; + + const invoiceLimit = 100; + let invoicesData: { items: InvoiceSummary[]; total: number } | null = null; + let offset = 0; + let total = 0; + const items: InvoiceSummary[] = []; + do { + const invoiceUrl = new URL(`${apiBaseUrl}/api/core/invoices`); + invoiceUrl.searchParams.set("limit", invoiceLimit.toString()); + invoiceUrl.searchParams.set("offset", offset.toString()); + if (includeArchived) { + invoiceUrl.searchParams.set("include_archived", "true"); + } + if (invoiceSearchQuery) { + invoiceUrl.searchParams.set("q", invoiceSearchQuery); + } + if (invoiceSort) { + invoiceUrl.searchParams.set("sort", invoiceSort); + } + if (invoiceOrder) { + invoiceUrl.searchParams.set("order", invoiceOrder); + } + const response = await fetch(invoiceUrl.toString(), { + headers: { + Authorization: `ApiKey ${apiKey}`, + }, + cache: "no-store", + }); + if (response.status === 401) { + redirect("/?login=1"); + } + if (!response.ok) { + break; + } + const data = (await response.json()) as { + items: InvoiceSummary[]; + total: number; + }; + total = data.total; + items.push(...data.items); + offset += invoiceLimit; + } while (offset < total); + invoicesData = { items, total }; + + if ( + activeTab === "overview" || + (activeTab === "webhooks" && activeWebhookTab === "settings") + ) { + const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, { + headers: { + Authorization: `ApiKey ${apiKey}`, + }, + cache: "no-store", + }); + if (response.status === 401) { + redirect("/?login=1"); + } + if (response.ok) { + webhooksData = (await response.json()) as WebhookSummary[]; + } + } + if (activeTab === "webhooks" && activeWebhookTab === "history") { + const historyUrl = new URL(`${apiBaseUrl}/api/core/webhooks/history`); + historyUrl.searchParams.set("limit", "50"); + const response = await fetch(historyUrl.toString(), { + headers: { + Authorization: `ApiKey ${apiKey}`, + }, + cache: "no-store", + }); + if (response.status === 401) { + redirect("/?login=1"); + } + if (response.ok) { + webhookHistory = (await response.json()) as WebhookDeliverySummary[]; + } + } + + const allInvoices = invoicesData?.items ?? []; + let profileData: ProfileSummary | null = null; + + if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") { + const response = await fetch(`${apiBaseUrl}/api/core/profile`, { + headers: { + Authorization: `ApiKey ${apiKey}`, + }, + cache: "no-store", + }); + if (response.status === 401) { + redirect("/?login=1"); + } + if (response.ok) { + profileData = (await response.json()) as ProfileSummary; + } + } + + const isToday = (value: string) => { + const date = new Date(value); + const today = new Date(); + return ( + date.getFullYear() === today.getFullYear() && + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate() + ); + }; + + const invoicesTodayCount = allInvoices.filter((invoice) => + isToday(invoice.created_at) + ).length; + const awaitingConfirmationCount = allInvoices.filter( + (invoice) => invoice.status === "payment_detected" + ).length; + const confirmedCount = allInvoices.filter( + (invoice) => invoice.status === "confirmed" + ).length; + + const tabBaseClass = + "inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5"; + const tabActiveClass = + "border-ink bg-ink text-cream shadow-[0_12px_24px_rgba(16,18,23,0.18)]"; + const subTabBaseClass = + "inline-flex items-center rounded-full border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5"; + const subTabActiveClass = + "border-ink bg-ink text-cream shadow-[0_10px_18px_rgba(16,18,23,0.18)]"; + + return ( +
+
+ +
+ {activeTab === "overview" ? ( +
+

Operational overview.

+

+ Track invoice flow and confirmation progress at a glance. +

+
+

+ Primary address +

+

+ {profileData?.payment_address ?? "Unavailable"} +

+

+ This is the wallet address used for your invoices. +

+
+
+ {[ + { + title: "Invoices today", + value: invoicesTodayCount.toString(), + detail: "Totals will appear once invoices are created.", + }, + { + title: "Invoices awaiting confirmation", + value: awaitingConfirmationCount.toString(), + detail: "Waiting for the configured confirmation target.", + }, + { + title: "Confirmed invoices", + value: confirmedCount.toString(), + detail: "Confirmed on-chain invoices will display here.", + }, + ].map((item) => ( +
+

+ {item.title} +

+

{item.value}

+

{item.detail}

+
+ ))} +
+
+

+ Webhook endpoints +

+ {webhooksData.length === 0 ? ( +

+ No webhooks configured yet.{" "} + + Add a webhook endpoint. + +

+ ) : ( +
+ {webhooksData.map((webhook) => ( +
+
+

{webhook.url}

+ + {webhook.active ? "Active" : "Paused"} + +
+

+ Events: {webhook.events.join(", ")} +

+
+ ))} +
+ )} +
+
+ We never hold funds. All payments move from the customer to your + wallet. +
+
+ ) : null} + {activeTab === "invoices" ? ( + + ) : null} + {activeTab === "profile" ? ( + <> + {apiKeyData ? ( +
+
+
+

Profile settings.

+

+ Manage credentials and delivery settings for your integration. +

+
+
+
+
+

Store id

+

+ Single-store per primary address. Use this id in the BTCPay + WooCommerce plugin store field. +

+
+

+ {profileData?.id ?? "Unavailable"} +

+
+
+
+

API key

+

+ Use this key with authenticated endpoints. +

+
+ +
+
+
+

Webhook secret

+

+ Sent with each delivery as the{" "} + X-Webhook-Secret header. +

+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ ) : ( +
+

API key

+

+ Sign in again to reveal your API key. +

+
+ )} + + ) : null} + {activeTab === "webhooks" ? ( +
+
+
+

Webhook endpoints.

+

+ Relay invoice state updates to your systems. Optional overrides let + you target different URLs per event. +

+
+
+ + {activeWebhookTab === "settings" ? ( +
+ +
+ ) : null} + {activeWebhookTab === "history" ? ( +
+ +
+ ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/ui/app/(app)/layout.tsx b/ui/app/(app)/layout.tsx new file mode 100644 index 0000000..39caf29 --- /dev/null +++ b/ui/app/(app)/layout.tsx @@ -0,0 +1,62 @@ +import Image from "next/image"; +import Link from "next/link"; +import { Suspense } from "react"; + +import "../(marketing)/marketing.css"; +import DonateModal from "../../components/donate-modal"; +import NavLink from "../../components/nav-link"; +import { logoutAction } from "./dashboard/actions"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+ + WOW Checkout logo + + + WOW Checkout + +
+ +
+ {children} +
+
+

wowcheckout.com - Non-custodial Wownero checkout software.

+

+ + GitHub + +

+
+ + + +
+
+ ); +} diff --git a/ui/app/(app)/login/actions.ts b/ui/app/(app)/login/actions.ts new file mode 100644 index 0000000..7c049d3 --- /dev/null +++ b/ui/app/(app)/login/actions.ts @@ -0,0 +1,57 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000"; + +export type LoginState = { + error: string | null; +}; + +export async function loginAction( + _prevState: LoginState, + formData: FormData +): Promise { + const paymentAddress = String(formData.get("payment_address") ?? "").trim(); + const viewKey = String(formData.get("view_key") ?? "").trim(); + + if (!paymentAddress || !viewKey) { + return { error: "Primary address and secret view key are required." }; + } + + const response = await fetch(`${apiBaseUrl}/api/core/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payment_address: paymentAddress, view_key: viewKey }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Unable to sign in."; + return { error: message }; + } + + const data = (await response.json()) as { + api_key: string; + webhook_secret: string; + store_id?: string; + }; + + const cookieStore = await cookies(); + cookieStore.set("wowcheckout_api_key", data.api_key, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + cookieStore.set("wowcheckout_webhook_secret", data.webhook_secret, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + + redirect("/dashboard"); +} diff --git a/ui/app/(app)/login/login-form.tsx b/ui/app/(app)/login/login-form.tsx new file mode 100644 index 0000000..02bb438 --- /dev/null +++ b/ui/app/(app)/login/login-form.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useFormState } from "react-dom"; + +import { loginAction, type LoginState } from "./actions"; + +const initialState: LoginState = { error: null }; + +const apiBaseUrl = + typeof window === "undefined" ? process.env.NEXT_PUBLIC_API_BASE_URL ?? "" : ""; +const base58Pattern = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; +const viewKeyPattern = /^[0-9a-fA-F]{64}$/; +const validationDelayMs = 450; + +const isLikelyPrimaryAddress = (value: string) => { + const trimmed = value.trim(); + if (trimmed.length !== 95) { + return false; + } + if (!trimmed.startsWith("4")) { + return false; + } + return base58Pattern.test(trimmed); +}; + +const isLikelySecretViewKey = (value: string) => viewKeyPattern.test(value.trim()); + +export default function LoginForm() { + const [state, formAction] = useFormState(loginAction, initialState); + const [paymentAddress, setPaymentAddress] = useState(""); + const [viewKey, setViewKey] = useState(""); + const [validationStatus, setValidationStatus] = useState< + "idle" | "checking" | "valid" | "invalid" | "error" + >("idle"); + const [validationMessage, setValidationMessage] = useState(null); + + const canSubmit = useMemo(() => { + if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) { + return false; + } + return validationStatus !== "checking" && validationStatus !== "invalid"; + }, [paymentAddress, validationStatus, viewKey]); + + useEffect(() => { + if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) { + setValidationStatus("idle"); + setValidationMessage(null); + return; + } + + const controller = new AbortController(); + const timeoutId = window.setTimeout(async () => { + setValidationStatus("checking"); + setValidationMessage(null); + try { + const response = await fetch(`${apiBaseUrl}/api/core/auth/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + payment_address: paymentAddress.trim(), + view_key: viewKey.trim(), + }), + cache: "no-store", + signal: controller.signal, + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = + detail?.detail ?? "Address and view key did not pass validation."; + setValidationStatus("invalid"); + setValidationMessage(message); + return; + } + + setValidationStatus("valid"); + } catch (error) { + if (controller.signal.aborted) { + return; + } + setValidationStatus("error"); + setValidationMessage( + "Unable to validate the address and view key yet. You can still sign in to confirm." + ); + } + }, validationDelayMs); + + return () => { + controller.abort(); + window.clearTimeout(timeoutId); + }; + }, [paymentAddress, viewKey]); + + return ( +
+
+
+ + {isLikelyPrimaryAddress(paymentAddress) ? ( + + + Valid + + ) : null} +
+