"""Periodic safety net for deposit crediting and external-spend detection (B-30), independent of scripthash-change notifications. Those notifications are the fast path, but nothing else re-verifies a user's balance against the chain if one is ever silently lost: `address_for_new_user`'s subscribe is best-effort (its own failure just logs, see electrum/listener.py), and on an otherwise healthy, long-lived connection there may be no reconnect for days — the only other event that re-subscribes everyone from scratch. Without this, a single lost subscription meant that user's deposits were never credited, indefinitely. This mirrors app/tx/reconcile.py's shape (a periodic sweep gated on the Electrum client being connected) but reuses ElectrumListener.refresh_user directly rather than re-implementing crediting/spend-detection, so the notification-driven and periodic paths can never behave differently from each other. """ import asyncio import logging from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker from app.db.models import User from app.electrum.listener import ElectrumListener from app.electrum.scripthash import address_to_scripthash logger = logging.getLogger(__name__) _SWEEP_INTERVAL_SECONDS = 300 class DepositReconciler: def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener): self._session_factory = session_factory self._listener = listener async def run(self) -> None: while True: await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) if self._listener.client is None: continue try: await self._sweep_once() except asyncio.CancelledError: raise except Exception: logger.exception("deposit reconciliation sweep failed") async def _sweep_once(self) -> None: """Round-robins over every user's address rather than only ones missing from the listener's in-memory `_scripthash_to_user` map: that map can't tell "never subscribed" apart from "subscribed, but this server silently stopped delivering notifications for it" — exactly the failure mode this exists to catch. One user failing (a transient network hiccup) must not stop the sweep from reaching the rest, mirroring poll_once's per-item isolation in tx/confirmation.py. """ async with self._session_factory() as session: users = (await session.scalars(select(User))).all() for user in users: if self._listener.client is None: return # connection dropped mid-sweep; the next reconnect's own _subscribe_all_users covers everyone scripthash = address_to_scripthash(user.address) try: await self._listener.refresh_user(user.id, scripthash) except Exception: logger.exception("deposit reconciliation failed for user_id=%s", user.id)