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)