poll_once marked a transaction confirmed on one server's `height > 0`, and reinstate_reappeared_utxos restored a coin written off as spent outside the platform on the active server's word alone, so a hostile active server could report a never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin back into a balance. - electrum/merkle.py: pure merkle-branch verification against a block header. - ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum must return a proof that recomputes to the merkle root of that block's header (block height and position checked too). The header is the tip the listener holds, or for an older block the one a majority of the quorum agrees on and that meets its own target; a height above our tip cannot be proven. Quorum rules are _corroborate_majority's. - poll_once takes the listener as `network`: the active server's history only nominates a candidate (txid, height); the row confirms only when the proof holds, and stays pending otherwise. With no network nothing confirms. ConfirmationPoller wires it. - The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed (txid, height) to the same proof instead of counting histories. - refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only; same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated, rechecking the mark under the lock. Nothing is committed in the read phase any more. - Broadcast fan-out, the third part of the finding, shipped with B-76. Tests changed because the spec changed: every poll_once call now passes a network that proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row "quorum does not confirm" test now asserts the claim goes to the proof. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
160 lines
7.2 KiB
Python
160 lines
7.2 KiB
Python
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)
|