Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)

- bet/withdrawal routes read listener.client after taking the user lock
- a reduced full-balance withdrawal is checked against the minimum again
- a bet covered only by pending balance answers balance_pending_confirmation
- a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent
- an abandoned payout clears winner/fee/reserve amounts with payout_txid
- the reconciler pings SSE only for rows it changed
- closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current
- one refresh per user at a time; a failed refresh no longer ends the Electrum session

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 01:08:14 +02:00
1 parent 24e5fc3d8b
commit 9520c80dcf
23 files changed
+445 -151

No files matched your search

+19 -1
View File
@@ -119,6 +119,7 @@ class ElectrumListener:
self._allow_no_quorum = allow_no_quorum
self._endpoint_index = 0
self._restore_checked = False
self._refresh_locks: dict[int, asyncio.Lock] = {}
self._scripthash_to_user: dict[str, int] = {}
# Retains address_for_new_user's fire-and-forget subscribe task so it
# can't be garbage-collected mid-flight, and so its exception (if any) is
@@ -1143,9 +1144,26 @@ class ElectrumListener:
scripthash, _status = await queue.get()
user_id = self._scripthash_to_user.get(scripthash)
if user_id is not None:
await self.refresh_user(user_id, scripthash)
try:
await self.refresh_user(user_id, scripthash)
except asyncio.CancelledError:
raise
except Exception:
# B-96 (DEP-16): one user's failed refresh (a coin already recorded, a
# corroboration error) ends neither the session nor the other users'
# notifications; a dropped connection ends it through wait_closed(), and
# this user is picked up again by the next refresh or the periodic sweep.
logger.warning("refresh of user %s failed after a notification", user_id, exc_info=True)
async def refresh_user(self, user_id: int, scripthash: str) -> None:
"""One refresh of a user at a time (B-96, DEP-15): a notification, the re-subscription
and the periodic sweep can ask together, and two of them reading the same new coin would
both try to credit it. The lock is the refresh's own, not the bet/withdrawal one, so a
corroboration in progress never delays a bet. Other users refresh in parallel."""
async with self._refresh_locks.setdefault(user_id, asyncio.Lock()):
await self._refresh_user_locked(user_id, scripthash)
async def _refresh_user_locked(self, user_id: int, scripthash: str) -> None:
"""Three phases, so no DB session is held across a network call (B-18),
same shape as _trigger_payout: read what's needed, corroborate every
balance-moving candidate against other servers — new credits (B-59) as