Files
plm-lottery/app/tx/confirmation.py
T

159 lines
7.2 KiB
Python
Raw Normal View History

import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import begin_immediate
from app.db.models import PendingTransaction
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
from app.tx.broadcast import adopt_version
from app.tx.pending_address import own_address_for
from app.tx.versions import txids_by_row
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
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."""
async with session_factory() as session:
# 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(
PendingTransaction.id,
PendingTransaction.current_txid,
PendingTransaction.kind,
PendingTransaction.user_id,
PendingTransaction.round_id,
).where(PendingTransaction.status == "pending")
)
).all()
# Resolved once per candidate while the session is still open, and cached
# 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.
scripthash_by_id: dict[int, str] = {}
# 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})
for pending_id, _txid, kind, user_id, round_id in candidates:
try:
address = await own_address_for(session, kind, user_id, round_id)
scripthash_by_id[pending_id] = address_to_scripthash(address)
except Exception:
logger.exception("could not derive the address for pending_transaction %s", pending_id)
confirmed = 0
history_cache: dict[str, list[dict]] = {}
for pending_id, txid, kind, _user_id, _round_id in candidates:
scripthash = scripthash_by_id.get(pending_id)
if scripthash is None:
continue # address derivation failed above; already logged
try:
if scripthash not in history_cache:
history_cache[scripthash] = await client.get_history(scripthash)
except Exception:
# 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)
continue
known_txids = txids_by_id[pending_id]
# 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.
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:
continue
# 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
async with session_factory() as session:
await begin_immediate(session) # B-88: read the row and write its outcome under one lock
row = await session.get(PendingTransaction, pending_id)
if row is None or row.status != "pending":
continue
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"])
row.status = "confirmed"
handler = _handlers.get(kind)
if handler is not None:
await handler(session, row)
await session.commit()
broadcaster.publish() # a bet/withdrawal/payout just confirmed — balance and/or round state changed
confirmed += 1
return confirmed
class ConfirmationPoller:
def __init__(
self,
session_factory: async_sessionmaker,
get_client: Callable[[], ElectrumClient | None],
network: ConfirmationProver | None = None,
):
self._session_factory = session_factory
self._get_client = get_client
self._network = network # the ElectrumListener, which holds the headers and asks the quorum
async def run(self) -> None:
while True:
client = self._get_client()
if client is not None:
try:
await poll_once(self._session_factory, client, self._network)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("confirmation poll failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)