Check confirmation/existence via scripthash history, not verbose replies (B-41)

poll_once and reconcile.py's existence check both called
blockchain.transaction.get(txid, verbose=True). Several Electrum server
implementations and versions reject the verbose flag outright
("verbose transactions are currently unsupported"), which would have
meant no confirmations and no reconciliation ever running against such
a server, read as a plain transport error. reconcile.py additionally
decided whether to abandon a transaction - releasing its funds - by
substring-matching the error text ("missing", "not found", ...), which
only works against ElectrumX's specific wording.

Both now ask blockchain.scripthash.get_history for the address that
owns every input of the transaction (a user's own address for a
bet/withdrawal, the pool address for a payout) and look for the txid in
the result: present with height > 0 means confirmed, present with
height <= 0 means still in the mempool, absent means the server
doesn't know it. get_history is a plain, universally-supported Electrum
method, and "not in the list" replaces the old substring-matching
entirely - no more guessing at error wording to decide whether to
release funds. History is cached per scripthash within one pass, since
every "payout" row shares the same pool address.

New app/tx/pending_address.py factors out own_address_for (the
address derivation was previously duplicated informally inside
tx/broadcast.py's signing context) so confirmation.py and reconcile.py
share one definition instead of two that could compute different
addresses for the same row.

tests/unit/test_confirmation.py and test_reconcile.py needed real User
rows and a master-key bootstrap they didn't have before, since address
derivation is now exercised for real rather than assumed. Suite grows
from 217 to 222 tests. BUGS.md moves B-41 to Previously fixed - no
Medium-severity finding remains open.
This commit is contained in:
2026-07-27 15:27:58 +02:00
parent 08c566d547
commit 4124dc08e6
8 changed files with 385 additions and 106 deletions
+39 -9
View File
@@ -7,7 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
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.pending_address import own_address_for
logger = logging.getLogger(__name__)
@@ -31,24 +33,52 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
candidates = (
await session.execute(
select(
PendingTransaction.id, PendingTransaction.current_txid, PendingTransaction.kind
PendingTransaction.id,
PendingTransaction.current_txid,
PendingTransaction.kind,
PendingTransaction.user_id,
).where(PendingTransaction.status == "pending")
)
).all()
# Resolved once per candidate while the session is still open, and cached
# by scripthash below — every "payout" row shares the same pool address,
# so this also avoids asking the server the same history twice per tick.
scripthash_by_id: dict[int, str] = {}
for pending_id, _txid, kind, user_id in candidates:
try:
address = await own_address_for(session, kind, user_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
for pending_id, txid, kind in candidates:
history_cache: dict[str, list[dict]] = {}
for pending_id, txid, kind, _user_id in candidates:
scripthash = scripthash_by_id.get(pending_id)
if scripthash is None:
continue # address derivation failed above; already logged
try:
tx = await client.get_transaction(txid, verbose=True)
if scripthash not in history_cache:
history_cache[scripthash] = await client.get_history(scripthash)
except Exception:
# One unresolvable txid must not stop the others: a tx the server no
# longer knows (dropped from the mempool, replaced) used to abort the
# whole pass, 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 check pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
# 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
if not tx or tx.get("confirmations", 0) < 1:
entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None)
# 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.
if entry is None or entry.get("height", 0) <= 0:
continue
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
if row is None or row.status != "pending":