From 696b04ec6d98e37345301db905c87a590a43a291 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 28 Mar 2026 12:22:33 -0400 Subject: [PATCH] feat: port upstream system status, health monitoring, connectivity caching Ports xmrcheckout upstream features (7 commits since 655d037): - SystemStatus model for reconciler heartbeat tracking - Monero connectivity caching in system status - Health status endpoints in routes - Hardened reconciler status updates with SQLAlchemyError handling - Status schema additions Adapted for Wownero: 3-decimal precision (0.001 WOW) in amount rounding. --- api/app/main.py | 81 ++++++++++++++++++++++- api/app/models.py | 14 ++++ api/app/monero_service.py | 21 ++++-- api/app/reconciler.py | 124 ++++++++++++++++++++++++++++++++++- api/app/routes.py | 132 +++++++++++++++++++++----------------- api/app/schemas.py | 14 +++- 6 files changed, 317 insertions(+), 69 deletions(-) diff --git a/api/app/main.py b/api/app/main.py index 62b3499..c917337 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -11,7 +11,7 @@ from .admin_routes import router as admin_router from .btcpay_routes import router as btcpay_router from .routes import router -app = FastAPI(title="wowcheckout.com API", version="0.1.0") +app = FastAPI(title="xmrcheckout.com API", version="0.1.0") logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper()) @@ -196,7 +196,7 @@ def startup(): connection.execute( text( "ALTER TABLE users " - "ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'wownero'" + "ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'monero'" ) ) connection.execute( @@ -248,13 +248,88 @@ def startup(): ) connection.execute( text( - "UPDATE users SET default_qr_logo = 'wownero' " + "UPDATE users SET default_qr_logo = 'monero' " "WHERE default_qr_logo IS NULL" ) ) connection.execute( text("CREATE INDEX IF NOT EXISTS ix_users_payment_address ON users (payment_address)") ) + connection.execute( + text( + "CREATE TABLE IF NOT EXISTS system_status (" + "name VARCHAR PRIMARY KEY, " + "last_reconcile_started_at TIMESTAMP WITH TIME ZONE, " + "last_reconcile_completed_at TIMESTAMP WITH TIME ZONE, " + "last_reconcile_error VARCHAR, " + "wallet_rpc VARCHAR, " + "daemon VARCHAR, " + "daemon_height INTEGER, " + "checked_at TIMESTAMP WITH TIME ZONE, " + "updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()" + ")" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS last_reconcile_started_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS last_reconcile_completed_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS last_reconcile_error VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS wallet_rpc VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS daemon VARCHAR" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS daemon_height INTEGER" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS checked_at TIMESTAMP WITH TIME ZONE" + ) + ) + connection.execute( + text( + "ALTER TABLE system_status " + "ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()" + ) + ) + connection.execute( + text( + "INSERT INTO system_status (name) VALUES ('reconciler') " + "ON CONFLICT (name) DO NOTHING" + ) + ) + connection.execute( + text( + "INSERT INTO system_status (name) VALUES ('monero_connectivity') " + "ON CONFLICT (name) DO NOTHING" + ) + ) finally: if lock_acquired: connection.execute( diff --git a/api/app/models.py b/api/app/models.py index 6e1c3d1..a12b186 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -133,6 +133,20 @@ class User(Base): updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) +class SystemStatus(Base): + __tablename__ = "system_status" + + name = Column(String, primary_key=True) + last_reconcile_started_at = Column(DateTime(timezone=True), nullable=True) + last_reconcile_completed_at = Column(DateTime(timezone=True), nullable=True) + last_reconcile_error = Column(String, nullable=True) + wallet_rpc = Column(String, nullable=True) + daemon = Column(String, nullable=True) + daemon_height = Column(Integer, nullable=True) + checked_at = Column(DateTime(timezone=True), nullable=True) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + class ProfileHistory(Base): __tablename__ = "profile_history" diff --git a/api/app/monero_service.py b/api/app/monero_service.py index e28d2cc..b3606ad 100644 --- a/api/app/monero_service.py +++ b/api/app/monero_service.py @@ -98,12 +98,17 @@ class MoneroWalletService: self._daemon_address = _normalize_daemon_address(MONERO_DAEMON_URL) self._wallet_dir = MONERO_WALLET_RPC_WALLET_DIR.strip() - def get_status(self) -> dict[str, str]: + def get_status(self) -> dict[str, str | int | None]: + daemon_height = self._daemon_height() for backend in self._backends: try: backend.client.raw_request("get_version") except Exception: - return {"wallet_rpc": "unreachable"} + return { + "wallet_rpc": "unreachable", + "daemon": "ok" if daemon_height is not None else "unreachable", + "daemon_height": daemon_height, + } if self._daemon_address: try: @@ -114,8 +119,16 @@ class MoneroWalletService: except RPCError as exc: message = str(exc) if "no connection to daemon" in message or "no_connection_to_daemon" in message: - return {"wallet_rpc": "ok", "daemon": "unreachable"} - return {"wallet_rpc": "ok", "daemon": "ok" if self._daemon_address else "unknown"} + return { + "wallet_rpc": "ok", + "daemon": "unreachable", + "daemon_height": daemon_height, + } + return { + "wallet_rpc": "ok", + "daemon": "ok" if self._daemon_address else "unknown", + "daemon_height": daemon_height, + } def create_subaddress(self, user: User, label: str) -> SubaddressResult: start_total = time.monotonic() diff --git a/api/app/reconciler.py b/api/app/reconciler.py index d949c99..bd0c0a3 100644 --- a/api/app/reconciler.py +++ b/api/app/reconciler.py @@ -6,16 +6,18 @@ from datetime import datetime, timedelta, timezone from decimal import Decimal, ROUND_DOWN from sqlalchemy import and_, or_, text +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from .btcpay_webhooks import dispatch_btcpay_webhooks from .config import INVOICE_RECONCILE_INTERVAL_SECONDS, LATE_PAYMENT_LOOKBACK_HOURS from .db import SessionLocal -from .models import Invoice, InvoiceTransfer, User +from .models import Invoice, InvoiceTransfer, SystemStatus, User from .monero_service import MoneroWalletService, TransferDetail from .webhooks import dispatch_webhooks logger = logging.getLogger(__name__) +MONERO_CONNECTIVITY_STATUS_NAME = "monero_connectivity" def main() -> None: @@ -26,12 +28,36 @@ def main() -> None: level_name = "INFO" level = getattr(logging, level_name.upper(), logging.INFO) logging.basicConfig(level=level) - service = MoneroWalletService() while True: + status_db: Session | None = None try: + status_db = SessionLocal() + service = MoneroWalletService() + _safe_update_monero_connectivity_status(status_db, service) + _safe_update_reconciler_status( + status_db, + started_at=datetime.now(timezone.utc), + completed_at=None, + error_message=None, + ) _reconcile_invoices(service) + _safe_update_reconciler_status( + status_db, + completed_at=datetime.now(timezone.utc), + error_message=None, + ) except Exception as exc: logger.exception("Invoice reconcile failed: %s", exc) + if status_db is None: + status_db = SessionLocal() + _safe_update_monero_connectivity_error(status_db) + _safe_update_reconciler_status( + status_db, + error_message=str(exc), + ) + finally: + if status_db is not None: + status_db.close() time.sleep(INVOICE_RECONCILE_INTERVAL_SECONDS) @@ -286,6 +312,100 @@ def _sync_invoice_transfers( return changed +def _update_reconciler_status( + db: Session, + *, + started_at: datetime | None = None, + completed_at: datetime | None = None, + error_message: str | None = None, +) -> None: + status_row = db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first() + if status_row is None: + status_row = SystemStatus(name="reconciler") + if started_at is not None: + status_row.last_reconcile_started_at = started_at + if completed_at is not None: + status_row.last_reconcile_completed_at = completed_at + status_row.last_reconcile_error = error_message + db.add(status_row) + db.commit() + + +def _update_monero_connectivity_status( + db: Session, + *, + wallet_rpc: str, + daemon: str, + daemon_height: int | None, +) -> None: + status_row = ( + db.query(SystemStatus) + .filter(SystemStatus.name == MONERO_CONNECTIVITY_STATUS_NAME) + .first() + ) + if status_row is None: + status_row = SystemStatus(name=MONERO_CONNECTIVITY_STATUS_NAME) + status_row.wallet_rpc = wallet_rpc + status_row.daemon = daemon + status_row.daemon_height = daemon_height + status_row.checked_at = datetime.now(timezone.utc) + db.add(status_row) + db.commit() + + +def _safe_update_monero_connectivity_status( + db: Session, + service: MoneroWalletService, +) -> None: + try: + status_payload = service.get_status() + _update_monero_connectivity_status( + db, + wallet_rpc=str(status_payload.get("wallet_rpc", "unreachable")), + daemon=str(status_payload.get("daemon", "unknown")), + daemon_height=( + int(status_payload["daemon_height"]) + if isinstance(status_payload.get("daemon_height"), int) + else None + ), + ) + except Exception: + db.rollback() + logger.warning("Unable to persist Monero connectivity status", exc_info=True) + + +def _safe_update_monero_connectivity_error(db: Session) -> None: + try: + _update_monero_connectivity_status( + db, + wallet_rpc="unreachable", + daemon="unknown", + daemon_height=None, + ) + except SQLAlchemyError: + db.rollback() + logger.warning("Unable to persist Monero connectivity fallback", exc_info=True) + + +def _safe_update_reconciler_status( + db: Session, + *, + started_at: datetime | None = None, + completed_at: datetime | None = None, + error_message: str | None = None, +) -> None: + try: + _update_reconciler_status( + db, + started_at=started_at, + completed_at=completed_at, + error_message=error_message, + ) + except SQLAlchemyError: + db.rollback() + logger.warning("Unable to persist reconciler status heartbeat", exc_info=True) + + def _xmr_to_atomic(amount: Decimal) -> int: diff --git a/api/app/routes.py b/api/app/routes.py index 29977a2..dc2879e 100644 --- a/api/app/routes.py +++ b/api/app/routes.py @@ -22,13 +22,15 @@ from .config import ( FOUNDER_PAYMENT_ADDRESS, FOUNDER_VIEW_KEY, INVOICE_DEFAULT_EXPIRY_HOURS, + INVOICE_RECONCILE_INTERVAL_SECONDS, OPEN_REGISTRATION, QR_STORAGE_DIR, ) from .db import get_db from .formatting import format_xmr_amount -from .models import Invoice, ProfileHistory, User, Webhook, WebhookDelivery -from .rates import get_wow_rate +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 from .schemas import ( ApiCredentialsResetRequest, @@ -42,6 +44,7 @@ from .schemas import ( LoginResponse, ProfileResponse, ProfileUpdate, + SystemStatusResponse, WebhookCreate, WebhookDeliveryResponse, WebhookResponse, @@ -58,7 +61,6 @@ from .security import ( ) from .webhooks import build_webhook_payload, dispatch_webhooks from .qr_codes import ensure_invoice_qr_png, invoice_qr_url, resolve_qr_settings - router = APIRouter() WEBHOOK_EVENTS = ( @@ -77,12 +79,57 @@ def _require_donations_enabled() -> None: ) MAX_QR_LOGO_DATA_URL_LENGTH = 120_000 +MONERO_CONNECTIVITY_STATUS_NAME = "monero_connectivity" def _get_user_for_api_key(db: Session, api_key: str) -> User | None: return db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first() +def _status_response_from_rows( + monero_status: SystemStatus | None, + reconciler_status: SystemStatus | None, +) -> SystemStatusResponse: + wallet_rpc = ( + str(monero_status.wallet_rpc) + if monero_status and monero_status.wallet_rpc + else "unreachable" + ) + daemon = ( + str(monero_status.daemon) + if monero_status and monero_status.daemon + else "unknown" + ) + daemon_height = monero_status.daemon_height if monero_status else None + return SystemStatusResponse( + wallet_rpc=wallet_rpc, + daemon=daemon, + daemon_height=daemon_height, + invoice_reconcile_interval_seconds=INVOICE_RECONCILE_INTERVAL_SECONDS, + last_reconcile_started_at=( + reconciler_status.last_reconcile_started_at if reconciler_status else None + ), + last_reconcile_completed_at=( + reconciler_status.last_reconcile_completed_at if reconciler_status else None + ), + last_reconcile_error=( + reconciler_status.last_reconcile_error if reconciler_status else None + ), + ) + + +def _load_public_system_status(db: Session) -> SystemStatusResponse: + reconciler_status = ( + db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first() + ) + monero_status = ( + db.query(SystemStatus) + .filter(SystemStatus.name == MONERO_CONNECTIVITY_STATUS_NAME) + .first() + ) + return _status_response_from_rows(monero_status, reconciler_status) + + def _get_founder_user(db: Session) -> User: if not FOUNDER_PAYMENT_ADDRESS or not FOUNDER_VIEW_KEY: raise HTTPException( @@ -331,7 +378,7 @@ def _resolve_invoice_amount( detail="currency is required when amount_fiat is provided", ) try: - quote = get_wow_rate(requested_currency) + quote = get_xmr_rate(requested_currency) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -345,7 +392,6 @@ def _resolve_invoice_amount( # Round UP to 3 decimal places so we never undercharge. # 3 decimals = 0.001 WOW — more than enough precision for Wownero. # See also: btcpay_routes.py for the same rounding. - # To change precision: adjust the Decimal quantize string here and in btcpay_routes.py. amount_xmr = (Decimal(requested_amount_fiat) / quote.rate).quantize( Decimal("0.001"), rounding=ROUND_UP ) @@ -361,63 +407,28 @@ def _resolve_invoice_amount( def _validate_payment_address_and_view_key(payment_address: str, view_key: str) -> None: - """Validate a Wownero primary address and its secret view key. - - Wownero uses 2-byte varint prefixes (unlike Monero's 1-byte), so the - standard monero Python library cannot parse them correctly. We do - base58-decode + keccak checksum + ed25519 view-key verification here. - """ - from binascii import hexlify, unhexlify - from monero import base58, ed25519 - from monero.keccak import keccak_256 - - # Wownero base58 prefix varints (first decoded byte) - _WOW_PRIMARY_BYTE0 = 178 # base58 prefix 4146 -> "Wo" - _WOW_SUBADDR_BYTE0 = 176 # base58 prefix 12208 -> "Ww" - _WOW_INTEGRATED_BYTE0 = 179 # base58 prefix 4147 - try: - decoded = bytearray(unhexlify(base58.decode(payment_address))) - except Exception: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Primary address is invalid", - ) - - # Verify keccak-256 checksum (last 4 bytes) - if decoded[-4:] != keccak_256(decoded[:-4]).digest()[:4]: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Primary address is invalid", - ) - - first_byte = decoded[0] - if first_byte == _WOW_SUBADDR_BYTE0: + address = Address(payment_address) + except ValueError as exc: + try: + SubAddress(payment_address) + except ValueError: + try: + IntegratedAddress(payment_address) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Primary address is invalid", + ) from exc + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Integrated addresses are not supported. Use the primary address.", + ) from exc raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Subaddresses are not supported. Use the primary address.", - ) - if first_byte == _WOW_INTEGRATED_BYTE0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Integrated addresses are not supported. Use the primary address.", - ) - if first_byte != _WOW_PRIMARY_BYTE0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Primary address is invalid", - ) - - # Wownero has 2-byte varint prefix -> keys start at offset 2 - pub_spend = decoded[2:34] - pub_view = decoded[34:66] - - # Derive public view key from the secret view key and compare - try: - derived_pub_view = ed25519.public_from_secret_hex(view_key) - if unhexlify(derived_pub_view) != bytes(pub_view): - raise ValueError("mismatch") - except Exception: + ) from exc + if not address.check_private_view_key(view_key): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Secret view key does not match the primary address", @@ -575,6 +586,11 @@ def get_donation_status( return _public_invoice_status_response(db, invoice, request) +@router.get("/api/core/public/system/status", response_model=SystemStatusResponse) +def get_public_system_status(db: Session = Depends(get_db)): + return _load_public_system_status(db) + + @router.post("/api/core/webhooks", response_model=WebhookResponse) def register_webhook( payload: WebhookCreate, diff --git a/api/app/schemas.py b/api/app/schemas.py index 7ecfb67..5d61fce 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -18,7 +18,7 @@ from .formatting import format_xmr_amount InvoiceStatus = Literal["pending", "payment_detected", "confirmed", "expired", "invalid"] BtcpayCheckoutStyle = Literal["standard", "btcpay_classic"] -QrLogoMode = Literal["wownero", "none", "custom"] +QrLogoMode = Literal["monero", "none", "custom"] class InvoiceCreate(BaseModel): @@ -184,13 +184,23 @@ 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) id: UUID payment_address: str default_confirmation_target: conint(ge=0, le=10) = 1 - default_qr_logo: QrLogoMode = "wownero" + default_qr_logo: QrLogoMode = "monero" default_qr_logo_data_url: str | None = None btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic" created_at: datetime