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"