From ef7ef8edab383c7d24f404cf09b82f9d7094025b Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Wed, 25 Mar 2026 16:16:53 +0000
Subject: [PATCH 1/7] Update UI Next.js security patch
---
ui/package-lock.json | 96 ++++++++++++++++++++++----------------------
ui/package.json | 4 +-
2 files changed, 50 insertions(+), 50 deletions(-)
diff --git a/ui/package-lock.json b/ui/package-lock.json
index f41a73c..7331a9c 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -8,7 +8,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",
"react-dom": "19.2.3",
@@ -21,7 +21,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"
@@ -721,15 +721,15 @@
}
},
"node_modules/@next/env": {
- "version": "15.5.9",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
- "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz",
+ "integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "15.5.9",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.9.tgz",
- "integrity": "sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.14.tgz",
+ "integrity": "sha512-ogBjgsFrPPz19abP3VwcYSahbkUOMMvJjxCOYWYndw+PydeMuLuB4XrvNkNutFrTjC9St2KFULRdKID8Sd/CMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -737,9 +737,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
- "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz",
+ "integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==",
"cpu": [
"arm64"
],
@@ -753,9 +753,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
- "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz",
+ "integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==",
"cpu": [
"x64"
],
@@ -769,9 +769,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
- "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz",
+ "integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==",
"cpu": [
"arm64"
],
@@ -785,9 +785,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
- "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz",
+ "integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==",
"cpu": [
"arm64"
],
@@ -801,9 +801,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
- "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz",
+ "integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==",
"cpu": [
"x64"
],
@@ -817,9 +817,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
- "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz",
+ "integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==",
"cpu": [
"x64"
],
@@ -833,9 +833,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
- "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz",
+ "integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==",
"cpu": [
"arm64"
],
@@ -849,9 +849,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
- "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz",
+ "integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==",
"cpu": [
"x64"
],
@@ -2676,13 +2676,13 @@
}
},
"node_modules/eslint-config-next": {
- "version": "15.5.9",
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.9.tgz",
- "integrity": "sha512-852JYI3NkFNzW8CqsMhI0K2CDRxTObdZ2jQJj5CtpEaOkYHn13107tHpNuD/h0WRpU4FAbCdUaxQsrfBtNK9Kw==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.14.tgz",
+ "integrity": "sha512-lmJ5F8ZgOYogq0qtH4L5SpxuASY2SPdOzqUprN2/56+P3GPsIpXaUWIJC66kYIH+yZdsM4nkHE5MIBP6s1NiBw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/eslint-plugin-next": "15.5.9",
+ "@next/eslint-plugin-next": "15.5.14",
"@rushstack/eslint-patch": "^1.10.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
@@ -4421,12 +4421,12 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "15.5.9",
- "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
- "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
+ "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
"license": "MIT",
"dependencies": {
- "@next/env": "15.5.9",
+ "@next/env": "15.5.14",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -4439,14 +4439,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 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 0fa82f0..27471aa 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",
"react-dom": "19.2.3",
@@ -24,7 +24,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"
From d22f1ba0dd31dfdad9226648d804b863930f2a6e Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Thu, 26 Mar 2026 14:07:28 +0000
Subject: [PATCH 2/7] Add Monero system status and reconciler heartbeat
---
api/app/main.py | 41 +++++++++++++++++++++++++++++++++++++
api/app/models.py | 10 +++++++++
api/app/monero_service.py | 21 +++++++++++++++----
api/app/reconciler.py | 43 ++++++++++++++++++++++++++++++++++++++-
api/app/routes.py | 33 +++++++++++++++++++++++++++++-
api/app/schemas.py | 10 +++++++++
6 files changed, 152 insertions(+), 6 deletions(-)
diff --git a/api/app/main.py b/api/app/main.py
index 9405309..da4333d 100644
--- a/api/app/main.py
+++ b/api/app/main.py
@@ -254,6 +254,47 @@ 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, "
+ "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 updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()"
+ )
+ )
+ connection.execute(
+ text(
+ "INSERT INTO system_status (name) VALUES ('reconciler') "
+ "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..7e49e4c 100644
--- a/api/app/models.py
+++ b/api/app/models.py
@@ -143,3 +143,13 @@ 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)
+ 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 e4bb594..78efb83 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 b846040..0ae1284 100644
--- a/api/app/reconciler.py
+++ b/api/app/reconciler.py
@@ -11,7 +11,7 @@ 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
@@ -28,10 +28,32 @@ def main() -> None:
logging.basicConfig(level=level)
service = MoneroWalletService()
while True:
+ status_db: Session | None = None
try:
+ status_db = SessionLocal()
+ _update_reconciler_status(
+ status_db,
+ started_at=datetime.now(timezone.utc),
+ completed_at=None,
+ error_message=None,
+ )
_reconcile_invoices(service)
+ _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()
+ _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)
@@ -252,6 +274,25 @@ 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 _xmr_to_atomic(amount: Decimal) -> int:
diff --git a/api/app/routes.py b/api/app/routes.py
index d4baef6..e78cf77 100644
--- a/api/app/routes.py
+++ b/api/app/routes.py
@@ -22,11 +22,12 @@ from .config import (
FOUNDER_PAYMENT_ADDRESS,
FOUNDER_VIEW_KEY,
INVOICE_DEFAULT_EXPIRY_HOURS,
+ INVOICE_RECONCILE_INTERVAL_SECONDS,
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
@@ -42,6 +43,7 @@ from .schemas import (
LoginResponse,
ProfileResponse,
ProfileUpdate,
+ SystemStatusResponse,
WebhookCreate,
WebhookDeliveryResponse,
WebhookResponse,
@@ -58,6 +60,7 @@ 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
+from .monero_service import MoneroWalletService
router = APIRouter()
@@ -536,6 +539,34 @@ 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)):
+ service = MoneroWalletService()
+ status_payload = service.get_status()
+ reconciler_status = (
+ db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first()
+ )
+ return SystemStatusResponse(
+ 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
+ ),
+ 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
+ ),
+ )
+
+
@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)
From ff96413d92c1fccda96f8c6eddba3e90c8c04c20 Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Thu, 26 Mar 2026 14:07:37 +0000
Subject: [PATCH 3/7] 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 ? (
Date: Thu, 26 Mar 2026 14:12:59 +0000
Subject: [PATCH 4/7] Harden reconciler status heartbeat startup
---
api/app/reconciler.py | 26 +++++++++++++++++++++++---
1 file changed, 23 insertions(+), 3 deletions(-)
diff --git a/api/app/reconciler.py b/api/app/reconciler.py
index 0ae1284..2590b81 100644
--- a/api/app/reconciler.py
+++ b/api/app/reconciler.py
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_DOWN
from sqlalchemy import and_, or_
+from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from .btcpay_webhooks import dispatch_btcpay_webhooks
@@ -31,14 +32,14 @@ def main() -> None:
status_db: Session | None = None
try:
status_db = SessionLocal()
- _update_reconciler_status(
+ _safe_update_reconciler_status(
status_db,
started_at=datetime.now(timezone.utc),
completed_at=None,
error_message=None,
)
_reconcile_invoices(service)
- _update_reconciler_status(
+ _safe_update_reconciler_status(
status_db,
completed_at=datetime.now(timezone.utc),
error_message=None,
@@ -47,7 +48,7 @@ def main() -> None:
logger.exception("Invoice reconcile failed: %s", exc)
if status_db is None:
status_db = SessionLocal()
- _update_reconciler_status(
+ _safe_update_reconciler_status(
status_db,
error_message=str(exc),
)
@@ -293,6 +294,25 @@ def _update_reconciler_status(
db.commit()
+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:
From d039d5a98455ede912f2ddf611a9b23687203282 Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Thu, 26 Mar 2026 14:14:26 +0000
Subject: [PATCH 5/7] Avoid false Monero outage states in UI
---
ui/app/(app)/dashboard/page.tsx | 70 ++++++++++++++-----
.../(marketing)/invoice/[invoiceId]/page.tsx | 69 +++++++++++++-----
ui/app/i/[invoiceId]/page.tsx | 69 +++++++++++++-----
3 files changed, 151 insertions(+), 57 deletions(-)
diff --git a/ui/app/(app)/dashboard/page.tsx b/ui/app/(app)/dashboard/page.tsx
index 256b473..bdea75c 100644
--- a/ui/app/(app)/dashboard/page.tsx
+++ b/ui/app/(app)/dashboard/page.tsx
@@ -85,6 +85,51 @@ type SystemStatusSummary = {
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,
}: {
@@ -259,6 +304,8 @@ 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;
@@ -377,29 +424,14 @@ export default async function DashboardPage({
- Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
+ {walletStatusBadge.label}
- Daemon{" "}
- {systemStatus?.daemon === "ok"
- ? "connected"
- : systemStatus?.daemon === "unknown"
- ? "not configured"
- : "down"}
+ {daemonStatusBadge.label}
diff --git a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
index 3bd7cdb..08d5cc1 100644
--- a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
+++ b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
@@ -47,6 +47,50 @@ type SystemStatusResponse = {
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") {
return "Payment detected";
@@ -219,6 +263,8 @@ export default async function InvoiceStatusDetailPage({
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 (
@@ -284,29 +330,14 @@ export default async function InvoiceStatusDetailPage({
- Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
+ {walletStatusBadge.label}
- Daemon{" "}
- {systemStatus?.daemon === "ok"
- ? "connected"
- : systemStatus?.daemon === "unknown"
- ? "not configured"
- : "down"}
+ {daemonStatusBadge.label}
diff --git a/ui/app/i/[invoiceId]/page.tsx b/ui/app/i/[invoiceId]/page.tsx
index eedba79..00b7226 100644
--- a/ui/app/i/[invoiceId]/page.tsx
+++ b/ui/app/i/[invoiceId]/page.tsx
@@ -62,6 +62,50 @@ type SystemStatusResponse = {
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") {
return "Payment detected";
@@ -161,6 +205,8 @@ export default async function BtcpayModalInvoicePage({
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";
@@ -268,29 +314,14 @@ export default async function BtcpayModalInvoicePage({
- Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
+ {walletStatusBadge.label}
- Daemon{" "}
- {systemStatus?.daemon === "ok"
- ? "connected"
- : systemStatus?.daemon === "unknown"
- ? "not configured"
- : "down"}
+ {daemonStatusBadge.label}
From f7e70e34364407745657aca37b5c2d5a74d5f42d Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Thu, 26 Mar 2026 14:17:47 +0000
Subject: [PATCH 6/7] Bump Monero Docker defaults to 0.18.4.6
---
docker-compose.yml | 16 ++++++++--------
docker/monero/Dockerfile | 2 +-
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index d5b0aa1..e5f7d63 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 \
From a15569c5356a4c9377d3186d32d933ed7d627724 Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Thu, 26 Mar 2026 15:17:18 +0000
Subject: [PATCH 7/7] Cache Monero connectivity in system status
---
api/app/main.py | 34 ++++++++++++++++++++
api/app/models.py | 4 +++
api/app/reconciler.py | 61 +++++++++++++++++++++++++++++++++++-
api/app/routes.py | 72 +++++++++++++++++++++++++++----------------
4 files changed, 144 insertions(+), 27 deletions(-)
diff --git a/api/app/main.py b/api/app/main.py
index da4333d..ac7324a 100644
--- a/api/app/main.py
+++ b/api/app/main.py
@@ -261,6 +261,10 @@ def startup():
"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()"
")"
)
@@ -283,6 +287,30 @@ def startup():
"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 "
@@ -295,6 +323,12 @@ def startup():
"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 7e49e4c..f15f259 100644
--- a/api/app/models.py
+++ b/api/app/models.py
@@ -152,4 +152,8 @@ class SystemStatus(Base):
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/reconciler.py b/api/app/reconciler.py
index 2590b81..320110f 100644
--- a/api/app/reconciler.py
+++ b/api/app/reconciler.py
@@ -17,6 +17,7 @@ from .monero_service import MoneroWalletService, TransferDetail
from .webhooks import dispatch_webhooks
logger = logging.getLogger(__name__)
+MONERO_CONNECTIVITY_STATUS_NAME = "monero_connectivity"
def main() -> None:
@@ -27,11 +28,12 @@ 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),
@@ -48,6 +50,7 @@ def main() -> None:
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),
@@ -294,6 +297,62 @@ def _update_reconciler_status(
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,
*,
diff --git a/api/app/routes.py b/api/app/routes.py
index e78cf77..e3fd146 100644
--- a/api/app/routes.py
+++ b/api/app/routes.py
@@ -60,8 +60,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
-from .monero_service import MoneroWalletService
-
router = APIRouter()
WEBHOOK_EVENTS = (
@@ -80,12 +78,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,30 +584,7 @@ def get_donation_status(
@router.get("/api/core/public/system/status", response_model=SystemStatusResponse)
def get_public_system_status(db: Session = Depends(get_db)):
- service = MoneroWalletService()
- status_payload = service.get_status()
- reconciler_status = (
- db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first()
- )
- return SystemStatusResponse(
- 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
- ),
- 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
- ),
- )
+ return _load_public_system_status(db)
@router.post("/api/core/webhooks", response_model=WebhookResponse)