Files
plm-lottery/app/deposits/reconcile.py
T

70 lines
2.9 KiB
Python
Raw Normal View History

"""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)