2026-07-21 10:25:57 +02:00
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
from collections.abc import Awaitable, Callable
|
2026-10-14 15:29:30 +02:00
|
|
|
from typing import Protocol
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
|
|
2026-09-29 16:26:01 +02:00
|
|
|
from app.db.base import begin_immediate
|
2026-07-21 10:25:57 +02:00
|
|
|
from app.db.models import PendingTransaction
|
|
|
|
|
from app.electrum.client import ElectrumClient
|
2026-07-27 15:27:58 +02:00
|
|
|
from app.electrum.scripthash import address_to_scripthash
|
2026-07-23 10:52:07 +02:00
|
|
|
from app.rounds.events import broadcaster
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
from app.tx.broadcast import adopt_version
|
2026-07-27 15:27:58 +02:00
|
|
|
from app.tx.pending_address import own_address_for
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
from app.tx.versions import txids_by_row
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
_POLL_INTERVAL_SECONDS = 10
|
|
|
|
|
|
|
|
|
|
ConfirmationHandler = Callable[[AsyncSession, PendingTransaction], Awaitable[None]]
|
|
|
|
|
_handlers: dict[str, ConfirmationHandler] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_handler(kind: str, handler: ConfirmationHandler) -> None:
|
|
|
|
|
"""Domain modules (bets, rounds, withdrawals) register here so this generic
|
|
|
|
|
poller can notify them when one of their outgoing txs gets its 1st
|
|
|
|
|
confirmation, without this module importing them directly."""
|
|
|
|
|
_handlers[kind] = handler
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 15:29:30 +02:00
|
|
|
class ConfirmationProver(Protocol):
|
|
|
|
|
"""The part of ElectrumListener a confirmation needs (B-83): the quorum's verdict on
|
|
|
|
|
a merkle proof of `txid` at `height`."""
|
|
|
|
|
|
|
|
|
|
async def confirmation_proven(self, txid: str, height: int) -> bool: ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient, network: ConfirmationProver | None) -> int:
|
|
|
|
|
"""One pass over the `pending` rows. The active server's history only nominates a
|
|
|
|
|
candidate: a row is confirmed when the quorum proves it with a merkle proof
|
|
|
|
|
(B-83, TX-22). With no `network` to ask nothing can be confirmed — the active
|
|
|
|
|
server's word alone is exactly what a hostile one would use to declare a payout that
|
|
|
|
|
was never broadcast confirmed."""
|
2026-07-21 10:25:57 +02:00
|
|
|
async with session_factory() as session:
|
2026-07-27 00:31:24 +02:00
|
|
|
# Plain columns, not entities: nothing then outlives the session, so this
|
|
|
|
|
# can't break if expire_on_commit is ever turned on (B-21).
|
|
|
|
|
candidates = (
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(
|
2026-07-27 15:27:58 +02:00
|
|
|
PendingTransaction.id,
|
|
|
|
|
PendingTransaction.current_txid,
|
|
|
|
|
PendingTransaction.kind,
|
|
|
|
|
PendingTransaction.user_id,
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
PendingTransaction.round_id,
|
2026-07-27 00:31:24 +02:00
|
|
|
).where(PendingTransaction.status == "pending")
|
|
|
|
|
)
|
2026-07-21 10:25:57 +02:00
|
|
|
).all()
|
|
|
|
|
|
2026-07-27 15:27:58 +02:00
|
|
|
# Resolved once per candidate while the session is still open, and cached
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
# by scripthash below — the payout rows of one round share its pool
|
|
|
|
|
# address (B-80), so this also avoids asking the server the same history twice.
|
2026-07-27 15:27:58 +02:00
|
|
|
scripthash_by_id: dict[int, str] = {}
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
# B-77 (TX-06, TX-19): every txid a row ever had, not only the current one — an
|
|
|
|
|
# earlier version of a bumped transaction may be the one that confirms.
|
|
|
|
|
txids_by_id = await txids_by_row(session, {pending_id: txid for pending_id, txid, *_ in candidates})
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
for pending_id, _txid, kind, user_id, round_id in candidates:
|
2026-07-27 15:27:58 +02:00
|
|
|
try:
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
address = await own_address_for(session, kind, user_id, round_id)
|
2026-07-27 15:27:58 +02:00
|
|
|
scripthash_by_id[pending_id] = address_to_scripthash(address)
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("could not derive the address for pending_transaction %s", pending_id)
|
|
|
|
|
|
2026-07-21 10:25:57 +02:00
|
|
|
confirmed = 0
|
2026-07-27 15:27:58 +02:00
|
|
|
history_cache: dict[str, list[dict]] = {}
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
for pending_id, txid, kind, _user_id, _round_id in candidates:
|
2026-07-27 15:27:58 +02:00
|
|
|
scripthash = scripthash_by_id.get(pending_id)
|
|
|
|
|
if scripthash is None:
|
|
|
|
|
continue # address derivation failed above; already logged
|
|
|
|
|
|
2026-07-27 00:31:24 +02:00
|
|
|
try:
|
2026-07-27 15:27:58 +02:00
|
|
|
if scripthash not in history_cache:
|
|
|
|
|
history_cache[scripthash] = await client.get_history(scripthash)
|
2026-07-27 00:31:24 +02:00
|
|
|
except Exception:
|
2026-07-27 15:27:58 +02:00
|
|
|
# One unresolvable scripthash must not stop the others: a tx the server
|
|
|
|
|
# no longer knows about (dropped from the mempool, replaced) used to
|
|
|
|
|
# abort the whole pass via a verbose blockchain.transaction.get call
|
|
|
|
|
# that some servers reject outright (B-41), so nothing confirmed again
|
|
|
|
|
# until an operator intervened (B-03). Abandoning such a row is
|
|
|
|
|
# app/tx/reconcile.py's job, not ours.
|
|
|
|
|
logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
|
2026-07-27 00:31:24 +02:00
|
|
|
continue
|
2026-07-27 15:27:58 +02:00
|
|
|
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
known_txids = txids_by_id[pending_id]
|
2026-07-27 15:27:58 +02:00
|
|
|
# height > 0 means confirmed at that height; 0 or absent means still in
|
|
|
|
|
# the mempool (or the server doesn't know this txid at all yet) — either
|
|
|
|
|
# way, not confirmed, so keep waiting.
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
entry = next(
|
|
|
|
|
(e for e in history_cache[scripthash] if e.get("tx_hash") in known_txids and e.get("height", 0) > 0),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
if entry is None:
|
2026-07-21 10:25:57 +02:00
|
|
|
continue
|
2026-07-27 15:27:58 +02:00
|
|
|
|
2026-10-14 15:29:30 +02:00
|
|
|
# B-83 (TX-22, ELC-26): the height is a claim of the active server. The quorum has
|
|
|
|
|
# to return a valid merkle proof of the txid against that block's header; without
|
|
|
|
|
# one the row stays pending and is looked at again on the next pass.
|
|
|
|
|
if network is None or not await network.confirmation_proven(entry["tx_hash"], entry["height"]):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"pending_transaction %s: %s reported at height %s but the quorum did not prove it — still pending",
|
|
|
|
|
pending_id,
|
|
|
|
|
entry["tx_hash"],
|
|
|
|
|
entry["height"],
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
|
2026-07-21 10:25:57 +02:00
|
|
|
async with session_factory() as session:
|
2026-09-29 16:26:01 +02:00
|
|
|
await begin_immediate(session) # B-88: read the row and write its outcome under one lock
|
2026-07-21 10:25:57 +02:00
|
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
|
|
|
if row is None or row.status != "pending":
|
|
|
|
|
continue
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
if entry["tx_hash"] != row.current_txid:
|
|
|
|
|
# The confirmed version is not the current one (B-77, DAT-14): realign
|
|
|
|
|
# the row and everything that recorded its txid *before* the handler runs.
|
|
|
|
|
await adopt_version(session, row, entry["tx_hash"])
|
2026-07-21 10:25:57 +02:00
|
|
|
row.status = "confirmed"
|
|
|
|
|
handler = _handlers.get(kind)
|
|
|
|
|
if handler is not None:
|
|
|
|
|
await handler(session, row)
|
|
|
|
|
await session.commit()
|
2026-07-23 10:52:07 +02:00
|
|
|
broadcaster.publish() # a bet/withdrawal/payout just confirmed — balance and/or round state changed
|
2026-07-21 10:25:57 +02:00
|
|
|
confirmed += 1
|
|
|
|
|
|
|
|
|
|
return confirmed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ConfirmationPoller:
|
2026-10-14 15:29:30 +02:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
session_factory: async_sessionmaker,
|
|
|
|
|
get_client: Callable[[], ElectrumClient | None],
|
|
|
|
|
network: ConfirmationProver | None = None,
|
|
|
|
|
):
|
2026-07-21 10:25:57 +02:00
|
|
|
self._session_factory = session_factory
|
|
|
|
|
self._get_client = get_client
|
2026-10-14 15:29:30 +02:00
|
|
|
self._network = network # the ElectrumListener, which holds the headers and asks the quorum
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
async def run(self) -> None:
|
|
|
|
|
while True:
|
|
|
|
|
client = self._get_client()
|
|
|
|
|
if client is not None:
|
|
|
|
|
try:
|
2026-10-14 15:29:30 +02:00
|
|
|
await poll_once(self._session_factory, client, self._network)
|
2026-07-21 10:25:57 +02:00
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
raise
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("confirmation poll failed")
|
|
|
|
|
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|