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)