diff --git a/README.md b/README.md index add85eb..f78498b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,47 @@ reconciler: - "api.example.com:192.168.1.100" ``` +### Troubleshooting first payment detection + +If an invoice stays at `0` confirmations or never moves out of `Awaiting funds`, check these in order: + +1. Open the dashboard overview and confirm: +- `Wallet RPC` shows `ok` +- `Daemon` shows `connected` +- `Current daemon height` is visible and keeps increasing +- `Last successful reconcile` is recent + +2. Open the public invoice page and confirm the same health indicators are present there. If block height is unavailable, the node path is broken before invoice detection can work. + +3. If the bundled wallet-rpc containers keep restarting, inspect their logs: + +``` +docker compose logs wallet-rpc-reconciler-1 +docker compose logs wallet-rpc-reconciler-2 +docker compose logs wallet-rpc-reconciler-3 +``` + +Common causes: +- wrong `MONERO_WALLET_RPC_WALLET_PASSWORD` +- wallet cache file does not match the `.keys` file +- daemon URL is unreachable from the wallet-rpc containers + +4. If you are running the bundled `monerod`, check whether it is synced: + +``` +docker compose logs monerod +``` + +Initial sync can take a long time. Payment detection is not reliable until the daemon is caught up. + +5. If the reconciler is not reporting a recent successful scan, inspect the API and reconciler logs: + +``` +docker compose logs api +docker compose logs reconciler +``` + +The reconciler is what updates invoice detection and confirmations. If it is down, invoices will not advance even when wallet-rpc and the daemon are healthy. ### 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. diff --git a/api/app/main.py b/api/app/main.py index 486a903..c917337 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -255,6 +255,81 @@ def startup(): connection.execute( text("CREATE INDEX IF NOT EXISTS ix_users_payment_address ON users (payment_address)") ) + connection.execute( + text( + "CREATE TABLE IF NOT EXISTS system_status (" + "name VARCHAR PRIMARY KEY, " + "last_reconcile_started_at TIMESTAMP WITH TIME ZONE, " + "last_reconcile_completed_at TIMESTAMP WITH TIME ZONE, " + "last_reconcile_error VARCHAR, " + "wallet_rpc VARCHAR, " + "daemon VARCHAR, " + "daemon_height INTEGER, " + "checked_at TIMESTAMP WITH TIME ZONE, " + "updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()" + ")" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS last_reconcile_started_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS last_reconcile_completed_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS last_reconcile_error VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS wallet_rpc VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS daemon VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS daemon_height INTEGER" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS checked_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()" + ) + ) + connection.execute( + text( + "INSERT INTO system_status (name) VALUES ('reconciler') " + "ON CONFLICT (name) DO NOTHING" + ) + ) + connection.execute( + text( + "INSERT INTO system_status (name) VALUES ('monero_connectivity') " + "ON CONFLICT (name) DO NOTHING" + ) + ) finally: if lock_acquired: connection.execute( diff --git a/api/app/models.py b/api/app/models.py index 0788801..f15f259 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -143,3 +143,17 @@ class ProfileHistory(Base): new_value = Column(String, nullable=True) value_encrypted = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class SystemStatus(Base): + __tablename__ = "system_status" + + name = Column(String, primary_key=True) + last_reconcile_started_at = Column(DateTime(timezone=True), nullable=True) + last_reconcile_completed_at = Column(DateTime(timezone=True), nullable=True) + last_reconcile_error = Column(String, nullable=True) + wallet_rpc = Column(String, nullable=True) + daemon = Column(String, nullable=True) + daemon_height = Column(Integer, nullable=True) + checked_at = Column(DateTime(timezone=True), nullable=True) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/api/app/monero_service.py b/api/app/monero_service.py index e28d2cc..b3606ad 100644 --- a/api/app/monero_service.py +++ b/api/app/monero_service.py @@ -98,12 +98,17 @@ class MoneroWalletService: 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]: + def get_status(self) -> dict[str, str | int | None]: + daemon_height = self._daemon_height() for backend in self._backends: try: backend.client.raw_request("get_version") except Exception: - return {"wallet_rpc": "unreachable"} + return { + "wallet_rpc": "unreachable", + "daemon": "ok" if daemon_height is not None else "unreachable", + "daemon_height": daemon_height, + } if self._daemon_address: try: @@ -114,8 +119,16 @@ class MoneroWalletService: 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"} + return { + "wallet_rpc": "ok", + "daemon": "unreachable", + "daemon_height": daemon_height, + } + return { + "wallet_rpc": "ok", + "daemon": "ok" if self._daemon_address else "unknown", + "daemon_height": daemon_height, + } def create_subaddress(self, user: User, label: str) -> SubaddressResult: start_total = time.monotonic() diff --git a/api/app/reconciler.py b/api/app/reconciler.py index d949c99..bd0c0a3 100644 --- a/api/app/reconciler.py +++ b/api/app/reconciler.py @@ -6,16 +6,18 @@ from datetime import datetime, timedelta, timezone from decimal import Decimal, ROUND_DOWN from sqlalchemy import and_, or_, text +from sqlalchemy.exc import SQLAlchemyError 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 .models import Invoice, InvoiceTransfer, SystemStatus, User from .monero_service import MoneroWalletService, TransferDetail from .webhooks import dispatch_webhooks logger = logging.getLogger(__name__) +MONERO_CONNECTIVITY_STATUS_NAME = "monero_connectivity" def main() -> None: @@ -26,12 +28,36 @@ def main() -> None: level_name = "INFO" level = getattr(logging, level_name.upper(), logging.INFO) logging.basicConfig(level=level) - service = MoneroWalletService() while True: + status_db: Session | None = None try: + status_db = SessionLocal() + service = MoneroWalletService() + _safe_update_monero_connectivity_status(status_db, service) + _safe_update_reconciler_status( + status_db, + started_at=datetime.now(timezone.utc), + completed_at=None, + error_message=None, + ) _reconcile_invoices(service) + _safe_update_reconciler_status( + status_db, + completed_at=datetime.now(timezone.utc), + error_message=None, + ) except Exception as exc: logger.exception("Invoice reconcile failed: %s", exc) + if status_db is None: + status_db = SessionLocal() + _safe_update_monero_connectivity_error(status_db) + _safe_update_reconciler_status( + status_db, + error_message=str(exc), + ) + finally: + if status_db is not None: + status_db.close() time.sleep(INVOICE_RECONCILE_INTERVAL_SECONDS) @@ -286,6 +312,100 @@ def _sync_invoice_transfers( return changed +def _update_reconciler_status( + db: Session, + *, + started_at: datetime | None = None, + completed_at: datetime | None = None, + error_message: str | None = None, +) -> None: + status_row = db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first() + if status_row is None: + status_row = SystemStatus(name="reconciler") + if started_at is not None: + status_row.last_reconcile_started_at = started_at + if completed_at is not None: + status_row.last_reconcile_completed_at = completed_at + status_row.last_reconcile_error = error_message + db.add(status_row) + db.commit() + + +def _update_monero_connectivity_status( + db: Session, + *, + wallet_rpc: str, + daemon: str, + daemon_height: int | None, +) -> None: + status_row = ( + db.query(SystemStatus) + .filter(SystemStatus.name == MONERO_CONNECTIVITY_STATUS_NAME) + .first() + ) + if status_row is None: + status_row = SystemStatus(name=MONERO_CONNECTIVITY_STATUS_NAME) + status_row.wallet_rpc = wallet_rpc + status_row.daemon = daemon + status_row.daemon_height = daemon_height + status_row.checked_at = datetime.now(timezone.utc) + db.add(status_row) + db.commit() + + +def _safe_update_monero_connectivity_status( + db: Session, + service: MoneroWalletService, +) -> None: + try: + status_payload = service.get_status() + _update_monero_connectivity_status( + db, + wallet_rpc=str(status_payload.get("wallet_rpc", "unreachable")), + daemon=str(status_payload.get("daemon", "unknown")), + daemon_height=( + int(status_payload["daemon_height"]) + if isinstance(status_payload.get("daemon_height"), int) + else None + ), + ) + except Exception: + db.rollback() + logger.warning("Unable to persist Monero connectivity status", exc_info=True) + + +def _safe_update_monero_connectivity_error(db: Session) -> None: + try: + _update_monero_connectivity_status( + db, + wallet_rpc="unreachable", + daemon="unknown", + daemon_height=None, + ) + except SQLAlchemyError: + db.rollback() + logger.warning("Unable to persist Monero connectivity fallback", exc_info=True) + + +def _safe_update_reconciler_status( + db: Session, + *, + started_at: datetime | None = None, + completed_at: datetime | None = None, + error_message: str | None = None, +) -> None: + try: + _update_reconciler_status( + db, + started_at=started_at, + completed_at=completed_at, + error_message=error_message, + ) + except SQLAlchemyError: + db.rollback() + logger.warning("Unable to persist reconciler status heartbeat", exc_info=True) + + def _xmr_to_atomic(amount: Decimal) -> int: diff --git a/api/app/routes.py b/api/app/routes.py index 5aee080..e6bf4b4 100644 --- a/api/app/routes.py +++ b/api/app/routes.py @@ -22,12 +22,13 @@ from .config import ( FOUNDER_PAYMENT_ADDRESS, FOUNDER_VIEW_KEY, INVOICE_DEFAULT_EXPIRY_HOURS, + INVOICE_RECONCILE_INTERVAL_SECONDS, OPEN_REGISTRATION, QR_STORAGE_DIR, ) from .db import get_db from .formatting import format_xmr_amount -from .models import Invoice, ProfileHistory, User, Webhook, WebhookDelivery +from .models import Invoice, ProfileHistory, SystemStatus, User, Webhook, WebhookDelivery from monero.address import Address, IntegratedAddress, SubAddress from .rates import get_xmr_rate from .subaddress_allocator import MAX_SUBADDRESS_INDEX, create_subaddress_for_user @@ -43,6 +44,7 @@ from .schemas import ( LoginResponse, ProfileResponse, ProfileUpdate, + SystemStatusResponse, WebhookCreate, WebhookDeliveryResponse, WebhookResponse, @@ -59,7 +61,6 @@ from .security import ( ) 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 = ( @@ -78,12 +79,57 @@ def _require_donations_enabled() -> None: ) MAX_QR_LOGO_DATA_URL_LENGTH = 120_000 +MONERO_CONNECTIVITY_STATUS_NAME = "monero_connectivity" 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 _status_response_from_rows( + monero_status: SystemStatus | None, + reconciler_status: SystemStatus | None, +) -> SystemStatusResponse: + wallet_rpc = ( + str(monero_status.wallet_rpc) + if monero_status and monero_status.wallet_rpc + else "unreachable" + ) + daemon = ( + str(monero_status.daemon) + if monero_status and monero_status.daemon + else "unknown" + ) + daemon_height = monero_status.daemon_height if monero_status else None + return SystemStatusResponse( + wallet_rpc=wallet_rpc, + daemon=daemon, + daemon_height=daemon_height, + invoice_reconcile_interval_seconds=INVOICE_RECONCILE_INTERVAL_SECONDS, + last_reconcile_started_at=( + reconciler_status.last_reconcile_started_at if reconciler_status else None + ), + last_reconcile_completed_at=( + reconciler_status.last_reconcile_completed_at if reconciler_status else None + ), + last_reconcile_error=( + reconciler_status.last_reconcile_error if reconciler_status else None + ), + ) + + +def _load_public_system_status(db: Session) -> SystemStatusResponse: + reconciler_status = ( + db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first() + ) + monero_status = ( + db.query(SystemStatus) + .filter(SystemStatus.name == MONERO_CONNECTIVITY_STATUS_NAME) + .first() + ) + return _status_response_from_rows(monero_status, reconciler_status) + + def _get_founder_user(db: Session) -> User: if not FOUNDER_PAYMENT_ADDRESS or not FOUNDER_VIEW_KEY: raise HTTPException( @@ -541,6 +587,11 @@ def get_donation_status( return _public_invoice_status_response(db, invoice, request) +@router.get("/api/core/public/system/status", response_model=SystemStatusResponse) +def get_public_system_status(db: Session = Depends(get_db)): + return _load_public_system_status(db) + + @router.post("/api/core/webhooks", response_model=WebhookResponse) def register_webhook( payload: WebhookCreate, diff --git a/api/app/schemas.py b/api/app/schemas.py index c20d094..5d61fce 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -184,6 +184,16 @@ class InvoiceStatusResponse(BaseModel): return format_xmr_amount(value) +class SystemStatusResponse(BaseModel): + wallet_rpc: Literal["ok", "unreachable"] + daemon: Literal["ok", "unreachable", "unknown"] + daemon_height: int | None = None + invoice_reconcile_interval_seconds: int + last_reconcile_started_at: datetime | None = None + last_reconcile_completed_at: datetime | None = None + last_reconcile_error: str | None = None + + class ProfileResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/docker-compose.yml b/docker-compose.yml index dbc3ef5..91a1efa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,8 +66,8 @@ services: build: context: ./docker/monero args: - MONERO_VERSION: ${MONERO_VERSION:-0.18.4.5} - image: monero-local:${MONERO_VERSION:-0.18.4.5} + MONERO_VERSION: ${MONERO_VERSION:-0.18.4.6} + image: monero-local:${MONERO_VERSION:-0.18.4.6} entrypoint: ["monerod"] command: [ @@ -97,8 +97,8 @@ services: build: context: ./docker/monero args: - MONERO_VERSION: ${MONERO_VERSION:-0.18.4.5} - image: monero-local:${MONERO_VERSION:-0.18.4.5} + MONERO_VERSION: ${MONERO_VERSION:-0.18.4.6} + image: monero-local:${MONERO_VERSION:-0.18.4.6} command: [ "monero-wallet-rpc", @@ -126,8 +126,8 @@ services: build: context: ./docker/monero args: - MONERO_VERSION: ${MONERO_VERSION:-0.18.4.5} - image: monero-local:${MONERO_VERSION:-0.18.4.5} + MONERO_VERSION: ${MONERO_VERSION:-0.18.4.6} + image: monero-local:${MONERO_VERSION:-0.18.4.6} command: [ "monero-wallet-rpc", @@ -155,8 +155,8 @@ services: build: context: ./docker/monero args: - MONERO_VERSION: ${MONERO_VERSION:-0.18.4.5} - image: monero-local:${MONERO_VERSION:-0.18.4.5} + MONERO_VERSION: ${MONERO_VERSION:-0.18.4.6} + image: monero-local:${MONERO_VERSION:-0.18.4.6} command: [ "monero-wallet-rpc", diff --git a/docker/monero/Dockerfile b/docker/monero/Dockerfile index 2a97695..e9fd53f 100644 --- a/docker/monero/Dockerfile +++ b/docker/monero/Dockerfile @@ -1,6 +1,6 @@ FROM debian:bookworm-slim -ARG MONERO_VERSION=0.18.4.4 +ARG MONERO_VERSION=0.18.4.6 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl bzip2 \ diff --git a/ui/app/(app)/dashboard/page.tsx b/ui/app/(app)/dashboard/page.tsx index 78ad4a1..bdea75c 100644 --- a/ui/app/(app)/dashboard/page.tsx +++ b/ui/app/(app)/dashboard/page.tsx @@ -8,6 +8,7 @@ import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-styl import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section"; import DefaultQrLogoSection from "../../../components/default-qr-logo-section"; import InvoicePanel from "../../../components/invoice-panel"; +import { formatRelativeTime } from "../../../components/relative-time"; import WebhookHistoryPanel from "../../../components/webhook-history-panel"; import WebhookSecretSection from "../../../components/webhook-secret-section"; import WebhookSection from "../../../components/webhook-section"; @@ -75,6 +76,60 @@ type ProfileSummary = { btcpay_checkout_style?: "standard" | "btcpay_classic"; created_at: string; }; +type SystemStatusSummary = { + wallet_rpc: "ok" | "unreachable"; + daemon: "ok" | "unreachable" | "unknown"; + daemon_height: number | null; + invoice_reconcile_interval_seconds: number; + last_reconcile_started_at: string | null; + last_reconcile_completed_at: string | null; + last_reconcile_error: string | null; +}; + +const walletRpcBadge = (systemStatus: SystemStatusSummary | null) => { + if (!systemStatus) { + return { + className: "bg-amber-100 text-amber-900", + label: "Wallet RPC unavailable", + }; + } + if (systemStatus.wallet_rpc === "ok") { + return { + className: "bg-emerald-100 text-emerald-900", + label: "Wallet RPC ok", + }; + } + return { + className: "bg-red-100 text-red-700", + label: "Wallet RPC down", + }; +}; + +const daemonBadge = (systemStatus: SystemStatusSummary | null) => { + if (!systemStatus) { + return { + className: "bg-amber-100 text-amber-900", + label: "Daemon unavailable", + }; + } + if (systemStatus.daemon === "ok") { + return { + className: "bg-emerald-100 text-emerald-900", + label: "Daemon connected", + }; + } + if (systemStatus.daemon === "unknown") { + return { + className: "bg-amber-100 text-amber-900", + label: "Daemon not configured", + }; + } + return { + className: "bg-red-100 text-red-700", + label: "Daemon down", + }; +}; + export default async function DashboardPage({ searchParams, }: { @@ -205,6 +260,7 @@ export default async function DashboardPage({ const allInvoices = invoicesData?.items ?? []; let profileData: ProfileSummary | null = null; + let systemStatus: SystemStatusSummary | null = null; if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") { const response = await fetch(`${apiBaseUrl}/api/core/profile`, { @@ -220,6 +276,14 @@ export default async function DashboardPage({ profileData = (await response.json()) as ProfileSummary; } } + if (activeTab === "overview") { + const response = await fetch(`${apiBaseUrl}/api/core/public/system/status`, { + cache: "no-store", + }); + if (response.ok) { + systemStatus = (await response.json()) as SystemStatusSummary; + } + } const isToday = (value: string) => { const date = new Date(value); @@ -240,7 +304,14 @@ export default async function DashboardPage({ const confirmedCount = allInvoices.filter( (invoice) => invoice.status === "confirmed" ).length; - + const walletStatusBadge = walletRpcBadge(systemStatus); + const daemonStatusBadge = daemonBadge(systemStatus); + const lastReconcileCompleted = systemStatus?.last_reconcile_completed_at + ? formatRelativeTime(systemStatus.last_reconcile_completed_at) + : null; + const lastReconcileStarted = systemStatus?.last_reconcile_started_at + ? formatRelativeTime(systemStatus.last_reconcile_started_at) + : null; 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 = @@ -346,6 +417,68 @@ export default async function DashboardPage({ )} +
+
+

+ Monero connectivity +

+
+ + {walletStatusBadge.label} + + + {daemonStatusBadge.label} + +
+
+
+
+

+ Current daemon height +

+

+ {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"} +

+
+
+

+ Detection poll interval +

+

+ {systemStatus?.invoice_reconcile_interval_seconds ?? 30}s +

+
+
+

+ Last successful reconcile +

+

+ {lastReconcileCompleted ?? "Not yet recorded"} +

+
+
+

+ Last reconcile start +

+

+ {lastReconcileStarted ?? "Not yet recorded"} +

+
+
+

+ If payments stay at 0 confirmations, check that the daemon is connected + and that the height keeps advancing. +

+ {systemStatus?.last_reconcile_error ? ( +

+ Last reconciler error: {systemStatus.last_reconcile_error} +

+ ) : null} +
We never hold funds. All payments move from the customer to your wallet. diff --git a/ui/app/(marketing)/docs/page.tsx b/ui/app/(marketing)/docs/page.tsx index 95cafb9..d01f16f 100644 --- a/ui/app/(marketing)/docs/page.tsx +++ b/ui/app/(marketing)/docs/page.tsx @@ -48,6 +48,11 @@ const endpointGroups = [ path: "/api/core/public/invoice/{invoice_id}/continue", description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).", }, + { + method: "GET", + path: "/api/core/public/system/status", + description: "Read daemon, wallet-rpc, block height, and reconciler heartbeat without auth.", + }, ], }, { @@ -257,6 +262,17 @@ const endpointRequirements = [ "Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).", ], }, + { + method: "GET", + path: "/api/core/public/system/status", + auth: "None", + required: [], + optional: [], + notes: [ + "Returns wallet-rpc reachability, daemon reachability, current daemon height, and reconciler heartbeat timestamps.", + "Use this endpoint to debug setup issues before sending a real payment.", + ], + }, { method: "GET", path: "/api/core/invoices", diff --git a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx index c67cebe..08d5cc1 100644 --- a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx +++ b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx @@ -37,6 +37,59 @@ type InvoiceStatusResponse = { detected_at: string | null; confirmed_at: string | null; }; +type SystemStatusResponse = { + wallet_rpc: "ok" | "unreachable"; + daemon: "ok" | "unreachable" | "unknown"; + daemon_height: number | null; + invoice_reconcile_interval_seconds: number; + last_reconcile_started_at: string | null; + last_reconcile_completed_at: string | null; + last_reconcile_error: string | null; +}; + +const walletRpcBadge = (systemStatus: SystemStatusResponse | null) => { + if (!systemStatus) { + return { + className: "bg-amber-100 text-amber-900", + label: "Wallet RPC unavailable", + }; + } + if (systemStatus.wallet_rpc === "ok") { + return { + className: "bg-emerald-100 text-emerald-900", + label: "Wallet RPC ok", + }; + } + return { + className: "bg-red-100 text-red-700", + label: "Wallet RPC down", + }; +}; + +const daemonBadge = (systemStatus: SystemStatusResponse | null) => { + if (!systemStatus) { + return { + className: "bg-amber-100 text-amber-900", + label: "Daemon unavailable", + }; + } + if (systemStatus.daemon === "ok") { + return { + className: "bg-emerald-100 text-emerald-900", + label: "Daemon connected", + }; + } + if (systemStatus.daemon === "unknown") { + return { + className: "bg-amber-100 text-amber-900", + label: "Daemon not configured", + }; + } + return { + className: "bg-red-100 text-red-700", + label: "Daemon down", + }; +}; const formatStatus = (status: InvoiceStatus) => { if (status === "payment_detected") { @@ -98,10 +151,12 @@ export default async function InvoiceStatusDetailPage({ params: Promise<{ invoiceId: string }>; }) { const { invoiceId } = await params; - const response = await fetch( - `${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, - { cache: "no-store" } - ); + const [response, systemStatusResponse] = await Promise.all([ + fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, { + cache: "no-store", + }), + fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }), + ]); if (response.status === 404) { return ( @@ -157,6 +212,9 @@ export default async function InvoiceStatusDetailPage({ } const invoice = (await response.json()) as InvoiceStatusResponse; + const systemStatus = systemStatusResponse.ok + ? ((await systemStatusResponse.json()) as SystemStatusResponse) + : null; const statusLabel = formatStatus(invoice.status); const confirmations = Math.max(0, invoice.confirmations ?? 0); const confirmationTarget = Math.max(0, invoice.confirmation_target); @@ -204,6 +262,9 @@ export default async function InvoiceStatusDetailPage({ const showConfirmedStamp = invoice.status === "confirmed"; const createdTimestamp = formatTimestamp(invoice.created_at); const expiresTimestamp = formatTimestamp(invoice.expires_at); + const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null); + const walletStatusBadge = walletRpcBadge(systemStatus); + const daemonStatusBadge = daemonBadge(systemStatus); return (
@@ -230,6 +291,9 @@ export default async function InvoiceStatusDetailPage({

Invoice ID: {invoiceId}

+

+ Current block height: {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"} +

This status page is public. Anyone with the link can view the current invoice state. @@ -259,6 +323,59 @@ export default async function InvoiceStatusDetailPage({

+
+
+

+ Monero node +

+
+ + {walletStatusBadge.label} + + + {daemonStatusBadge.label} + +
+
+
+
+

+ Current block height +

+

+ {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"} +

+
+
+

+ Detection poll +

+

+ {systemStatus?.invoice_reconcile_interval_seconds ?? 30}s +

+
+
+

+ Last successful scan +

+

+ {lastReconcileCompleted.label} +

+
+
+ {systemStatus?.last_reconcile_error ? ( +

+ Reconciler error: {systemStatus.last_reconcile_error} +

+ ) : null} +

diff --git a/ui/app/i/[invoiceId]/page.tsx b/ui/app/i/[invoiceId]/page.tsx index a5a6016..00b7226 100644 --- a/ui/app/i/[invoiceId]/page.tsx +++ b/ui/app/i/[invoiceId]/page.tsx @@ -52,6 +52,59 @@ type InvoiceStatusResponse = { quoted_at: string; } | null; }; +type SystemStatusResponse = { + wallet_rpc: "ok" | "unreachable"; + daemon: "ok" | "unreachable" | "unknown"; + daemon_height: number | null; + invoice_reconcile_interval_seconds: number; + last_reconcile_started_at: string | null; + last_reconcile_completed_at: string | null; + last_reconcile_error: string | null; +}; + +const walletRpcBadge = (systemStatus: SystemStatusResponse | null) => { + if (!systemStatus) { + return { + className: "bg-amber-100 text-amber-900", + label: "Wallet RPC unavailable", + }; + } + if (systemStatus.wallet_rpc === "ok") { + return { + className: "bg-emerald-100 text-emerald-900", + label: "Wallet RPC ok", + }; + } + return { + className: "bg-red-100 text-red-700", + label: "Wallet RPC down", + }; +}; + +const daemonBadge = (systemStatus: SystemStatusResponse | null) => { + if (!systemStatus) { + return { + className: "bg-amber-100 text-amber-900", + label: "Daemon unavailable", + }; + } + if (systemStatus.daemon === "ok") { + return { + className: "bg-emerald-100 text-emerald-900", + label: "Daemon connected", + }; + } + if (systemStatus.daemon === "unknown") { + return { + className: "bg-amber-100 text-amber-900", + label: "Daemon not configured", + }; + } + return { + className: "bg-red-100 text-red-700", + label: "Daemon down", + }; +}; const formatStatus = (status: InvoiceStatus) => { if (status === "payment_detected") { @@ -93,10 +146,12 @@ export default async function BtcpayModalInvoicePage({ params: Promise<{ invoiceId: string }>; }) { const { invoiceId } = await params; - const response = await fetch( - `${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, - { cache: "no-store" } - ); + const [response, systemStatusResponse] = await Promise.all([ + fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, { + cache: "no-store", + }), + fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }), + ]); if (response.status === 404) { return ( @@ -139,6 +194,9 @@ export default async function BtcpayModalInvoicePage({ } const invoice = (await response.json()) as InvoiceStatusResponse; + const systemStatus = systemStatusResponse.ok + ? ((await systemStatusResponse.json()) as SystemStatusResponse) + : null; const statusLabel = formatStatus(invoice.status); const confirmations = Math.max(0, invoice.confirmations ?? 0); const confirmationTarget = Math.max(0, invoice.confirmation_target); @@ -146,6 +204,9 @@ export default async function BtcpayModalInvoicePage({ invoice.status === "payment_detected" || invoice.status === "confirmed"; const createdTimestamp = formatTimestamp(invoice.created_at); const expiresTimestamp = formatTimestamp(invoice.expires_at); + const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null); + const walletStatusBadge = walletRpcBadge(systemStatus); + const daemonStatusBadge = daemonBadge(systemStatus); const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency); const useClassicCheckout = isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic"; @@ -246,6 +307,59 @@ export default async function BtcpayModalInvoicePage({

+
+
+

+ Monero node +

+
+ + {walletStatusBadge.label} + + + {daemonStatusBadge.label} + +
+
+
+
+

+ Current block height +

+

+ {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"} +

+
+
+

+ Detection poll +

+

+ {systemStatus?.invoice_reconcile_interval_seconds ?? 30}s +

+
+
+

+ Last successful scan +

+

+ {lastReconcileCompleted.label} +

+
+
+ {systemStatus?.last_reconcile_error ? ( +

+ Reconciler error: {systemStatus.last_reconcile_error} +

+ ) : null} +
{checkoutContinueAvailable ? ( = 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.7", - "@next/swc-darwin-x64": "15.5.7", - "@next/swc-linux-arm64-gnu": "15.5.7", - "@next/swc-linux-arm64-musl": "15.5.7", - "@next/swc-linux-x64-gnu": "15.5.7", - "@next/swc-linux-x64-musl": "15.5.7", - "@next/swc-win32-arm64-msvc": "15.5.7", - "@next/swc-win32-x64-msvc": "15.5.7", + "@next/swc-darwin-arm64": "15.5.14", + "@next/swc-darwin-x64": "15.5.14", + "@next/swc-linux-arm64-gnu": "15.5.14", + "@next/swc-linux-arm64-musl": "15.5.14", + "@next/swc-linux-x64-gnu": "15.5.14", + "@next/swc-linux-x64-musl": "15.5.14", + "@next/swc-win32-arm64-msvc": "15.5.14", + "@next/swc-win32-x64-msvc": "15.5.14", "sharp": "^0.34.3" }, "peerDependencies": { diff --git a/ui/package.json b/ui/package.json index 806dad9..9a525f5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,7 +11,7 @@ "dependencies": { "doctrine": "^3.0.0", "esutils": "^2.0.3", - "next": "15.5.9", + "next": "15.5.14", "qrcode": "1.5.4", "react": "19.2.3", "@marsidev/react-turnstile": "^1.4.2", @@ -26,7 +26,7 @@ "@types/react-dom": "18.3.0", "autoprefixer": "^10.4.23", "eslint": "9.9.0", - "eslint-config-next": "15.5.9", + "eslint-config-next": "15.5.14", "postcss": "^8.5.6", "tailwindcss": "^3.4.14", "typescript": "5.5.4"