Files
plm-lottery/app/deposits/reconcile.py
T
davide 63df38d30b Add a periodic deposit reconciler, and stop losing subscribe tasks (B-30)
Deposits were credited exclusively by scripthash-change notifications,
with nothing re-verifying a user's balance against the chain if a
subscription was ever silently lost. address_for_new_user's subscribe
was fire-and-forget: the task wasn't retained, so it could be
garbage-collected mid-flight, and any failure (including self.client
turning None between the check and the task running) vanished into
asyncio's default unretrieved-exception handler instead of being
logged anywhere. On an otherwise healthy, long-lived connection there
may be no reconnect for days to re-subscribe everyone, so a user in
that state never saw their deposits.

address_for_new_user now retains the task and logs its exception if
it fails. New app/deposits/reconcile.py adds DepositReconciler, a
periodic sweep (every 5 minutes, gated on the Electrum client being
connected, same shape as tx/reconcile.py) that round-robins over every
user and calls the listener's own refresh_user (renamed from
_refresh_user since it's now called from outside the class) - so the
notification-driven and periodic paths can never behave differently.
Deliberately sweeps every user rather than only ones missing from the
in-memory scripthash map, since that map can't tell "never subscribed"
apart from "subscribed, but the server stopped delivering
notifications for it". Wired into app/main.py's lifespan alongside the
other three background reconcilers.

Suite grows from 176 to 182 tests. BUGS.md moves B-30 to Previously
fixed.
2026-07-27 10:35:23 +02:00

70 lines
2.9 KiB
Python

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