70 lines
2.9 KiB
Python
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)
|