From ff96413d92c1fccda96f8c6eddba3e90c8c04c20 Mon Sep 17 00:00:00 2001 From: xmrcheckout Date: Thu, 26 Mar 2026 14:07:37 +0000 Subject: [PATCH] Surface Monero health status in UI and docs --- README.md | 42 +++++++ ui/app/(app)/dashboard/page.tsx | 103 +++++++++++++++++- ui/app/(marketing)/docs/page.tsx | 16 +++ .../(marketing)/invoice/[invoiceId]/page.tsx | 94 +++++++++++++++- ui/app/i/[invoiceId]/page.tsx | 91 +++++++++++++++- 5 files changed, 337 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cf79681..f59c3fc 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,48 @@ If you’re running the bundled `monerod` service: docker compose --profile local-daemon up --build -d ``` +### 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/ui/app/(app)/dashboard/page.tsx b/ui/app/(app)/dashboard/page.tsx index 78ad4a1..256b473 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,15 @@ 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; +}; export default async function DashboardPage({ searchParams, }: { @@ -205,6 +215,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 +231,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 +259,12 @@ export default async function DashboardPage({ const confirmedCount = allInvoices.filter( (invoice) => invoice.status === "confirmed" ).length; - + 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 +370,83 @@ export default async function DashboardPage({ )} +
+
+

+ Monero connectivity +

+
+ + Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"} + + + Daemon{" "} + {systemStatus?.daemon === "ok" + ? "connected" + : systemStatus?.daemon === "unknown" + ? "not configured" + : "down"} + +
+
+
+
+

+ 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..3bd7cdb 100644 --- a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx +++ b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx @@ -37,6 +37,15 @@ 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 formatStatus = (status: InvoiceStatus) => { if (status === "payment_detected") { @@ -98,10 +107,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 +168,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 +218,7 @@ 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); return (
@@ -230,6 +245,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 +277,74 @@ export default async function InvoiceStatusDetailPage({

+
+
+

+ Monero node +

+
+ + Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"} + + + Daemon{" "} + {systemStatus?.daemon === "ok" + ? "connected" + : systemStatus?.daemon === "unknown" + ? "not configured" + : "down"} + +
+
+
+
+

+ 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..eedba79 100644 --- a/ui/app/i/[invoiceId]/page.tsx +++ b/ui/app/i/[invoiceId]/page.tsx @@ -52,6 +52,15 @@ 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 formatStatus = (status: InvoiceStatus) => { if (status === "payment_detected") { @@ -93,10 +102,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 +150,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 +160,7 @@ 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 isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency); const useClassicCheckout = isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic"; @@ -246,6 +261,74 @@ export default async function BtcpayModalInvoicePage({

+
+
+

+ Monero node +

+
+ + Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"} + + + Daemon{" "} + {systemStatus?.daemon === "ok" + ? "connected" + : systemStatus?.daemon === "unknown" + ? "not configured" + : "down"} + +
+
+
+
+

+ 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 ? (