merge upstream: system status, health monitoring, connectivity caching

Merges xmrcheckout/xmrcheckout main (7 commits since 655d037):
- Add Monero system status and reconciler heartbeat
- Surface Monero health status in UI (dashboard + invoice pages)
- Harden reconciler status heartbeat startup
- Avoid false Monero outage states in UI
- Cache Monero connectivity in system status
- Next.js security patch (15.5.9 → 15.5.14)
- Bump Monero Docker defaults to 0.18.4.6

Resolved conflicts in reconciler.py (kept our advisory lock + upstream
heartbeat), routes.py (kept both imports), README.md (kept both sections).
This commit is contained in:
jwinterm
2026-03-28 12:20:32 -04:00
15 changed files with 780 additions and 76 deletions
+75
View File
@@ -255,6 +255,81 @@ def startup():
connection.execute(
text("CREATE INDEX IF NOT EXISTS ix_users_payment_address ON users (payment_address)")
)
connection.execute(
text(
"CREATE TABLE IF NOT EXISTS system_status ("
"name VARCHAR PRIMARY KEY, "
"last_reconcile_started_at TIMESTAMP WITH TIME ZONE, "
"last_reconcile_completed_at TIMESTAMP WITH TIME ZONE, "
"last_reconcile_error VARCHAR, "
"wallet_rpc VARCHAR, "
"daemon VARCHAR, "
"daemon_height INTEGER, "
"checked_at TIMESTAMP WITH TIME ZONE, "
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()"
")"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS last_reconcile_started_at TIMESTAMP WITH TIME ZONE"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS last_reconcile_completed_at TIMESTAMP WITH TIME ZONE"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS last_reconcile_error VARCHAR"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS wallet_rpc VARCHAR"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS daemon VARCHAR"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS daemon_height INTEGER"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS checked_at TIMESTAMP WITH TIME ZONE"
)
)
connection.execute(
text(
"ALTER TABLE system_status "
"ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()"
)
)
connection.execute(
text(
"INSERT INTO system_status (name) VALUES ('reconciler') "
"ON CONFLICT (name) DO NOTHING"
)
)
connection.execute(
text(
"INSERT INTO system_status (name) VALUES ('monero_connectivity') "
"ON CONFLICT (name) DO NOTHING"
)
)
finally:
if lock_acquired:
connection.execute(
+14
View File
@@ -143,3 +143,17 @@ class ProfileHistory(Base):
new_value = Column(String, nullable=True)
value_encrypted = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class SystemStatus(Base):
__tablename__ = "system_status"
name = Column(String, primary_key=True)
last_reconcile_started_at = Column(DateTime(timezone=True), nullable=True)
last_reconcile_completed_at = Column(DateTime(timezone=True), nullable=True)
last_reconcile_error = Column(String, nullable=True)
wallet_rpc = Column(String, nullable=True)
daemon = Column(String, nullable=True)
daemon_height = Column(Integer, nullable=True)
checked_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+17 -4
View File
@@ -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()
+122 -2
View File
@@ -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:
+53 -2
View File
@@ -22,12 +22,13 @@ from .config import (
FOUNDER_PAYMENT_ADDRESS,
FOUNDER_VIEW_KEY,
INVOICE_DEFAULT_EXPIRY_HOURS,
INVOICE_RECONCILE_INTERVAL_SECONDS,
OPEN_REGISTRATION,
QR_STORAGE_DIR,
)
from .db import get_db
from .formatting import format_xmr_amount
from .models import Invoice, ProfileHistory, User, Webhook, WebhookDelivery
from .models import Invoice, ProfileHistory, SystemStatus, User, Webhook, WebhookDelivery
from monero.address import Address, IntegratedAddress, SubAddress
from .rates import get_xmr_rate
from .subaddress_allocator import MAX_SUBADDRESS_INDEX, create_subaddress_for_user
@@ -43,6 +44,7 @@ from .schemas import (
LoginResponse,
ProfileResponse,
ProfileUpdate,
SystemStatusResponse,
WebhookCreate,
WebhookDeliveryResponse,
WebhookResponse,
@@ -59,7 +61,6 @@ from .security import (
)
from .webhooks import build_webhook_payload, dispatch_webhooks
from .qr_codes import ensure_invoice_qr_png, invoice_qr_url, resolve_qr_settings
router = APIRouter()
WEBHOOK_EVENTS = (
@@ -78,12 +79,57 @@ def _require_donations_enabled() -> None:
)
MAX_QR_LOGO_DATA_URL_LENGTH = 120_000
MONERO_CONNECTIVITY_STATUS_NAME = "monero_connectivity"
def _get_user_for_api_key(db: Session, api_key: str) -> User | None:
return db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first()
def _status_response_from_rows(
monero_status: SystemStatus | None,
reconciler_status: SystemStatus | None,
) -> SystemStatusResponse:
wallet_rpc = (
str(monero_status.wallet_rpc)
if monero_status and monero_status.wallet_rpc
else "unreachable"
)
daemon = (
str(monero_status.daemon)
if monero_status and monero_status.daemon
else "unknown"
)
daemon_height = monero_status.daemon_height if monero_status else None
return SystemStatusResponse(
wallet_rpc=wallet_rpc,
daemon=daemon,
daemon_height=daemon_height,
invoice_reconcile_interval_seconds=INVOICE_RECONCILE_INTERVAL_SECONDS,
last_reconcile_started_at=(
reconciler_status.last_reconcile_started_at if reconciler_status else None
),
last_reconcile_completed_at=(
reconciler_status.last_reconcile_completed_at if reconciler_status else None
),
last_reconcile_error=(
reconciler_status.last_reconcile_error if reconciler_status else None
),
)
def _load_public_system_status(db: Session) -> SystemStatusResponse:
reconciler_status = (
db.query(SystemStatus).filter(SystemStatus.name == "reconciler").first()
)
monero_status = (
db.query(SystemStatus)
.filter(SystemStatus.name == MONERO_CONNECTIVITY_STATUS_NAME)
.first()
)
return _status_response_from_rows(monero_status, reconciler_status)
def _get_founder_user(db: Session) -> User:
if not FOUNDER_PAYMENT_ADDRESS or not FOUNDER_VIEW_KEY:
raise HTTPException(
@@ -541,6 +587,11 @@ def get_donation_status(
return _public_invoice_status_response(db, invoice, request)
@router.get("/api/core/public/system/status", response_model=SystemStatusResponse)
def get_public_system_status(db: Session = Depends(get_db)):
return _load_public_system_status(db)
@router.post("/api/core/webhooks", response_model=WebhookResponse)
def register_webhook(
payload: WebhookCreate,
+10
View File
@@ -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)