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
+11
View File
@@ -169,6 +169,17 @@ class ElectrumClient:
async def listunspent(self, scripthash: str) -> list[dict]:
return await self.request("blockchain.scripthash.listunspent", [scripthash])
async def get_history(self, scripthash: str) -> list[dict]:
"""Every transaction touching `scripthash`, each as {"tx_hash", "height"} —
height > 0 means confirmed at that height, height <= 0 means still in the
mempool. Used instead of blockchain.transaction.get's verbose=True mode
for confirmation/existence checks (B-41): several Electrum server
implementations and versions reject the verbose flag outright ("verbose
transactions are currently unsupported"), while get_history is a plain,
universally-supported method every server must implement.
"""
return await self.request("blockchain.scripthash.get_history", [scripthash])
async def broadcast(self, raw_tx_hex: str) -> str:
return await self.request("blockchain.transaction.broadcast", [raw_tx_hex])
+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":
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User
from app.wallet.hd import derive_pool_address, derive_user_address
async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) -> str:
"""The address that owns every input of a PendingTransaction of this kind —
a user's own address for a bet/withdrawal, the pool address for a payout.
All our builders only ever spend one address's UTXOs per tx (see
tx/broadcast.py:_signing_context, which derives the same address alongside
the signing key it also needs).
Shared by tx/confirmation.py and tx/reconcile.py (B-41): both now check
blockchain.scripthash.get_history for this address instead of asking
blockchain.transaction.get for a verbose reply, so the two can't derive
different addresses for the same row.
"""
if kind == "payout":
return derive_pool_address()
user = await session.get(User, user_id)
return derive_user_address(user.derivation_index)
+32 -22
View File
@@ -36,7 +36,9 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal
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
from app.wallet.balance import recompute_balance
logger = logging.getLogger(__name__)
@@ -72,22 +74,21 @@ class PendingTransactionReconciler:
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def _tx_exists_on_chain(client: ElectrumClient, txid: str) -> bool:
"""True if the server knows this txid at all (mempool or mined). An error reply
means "unknown", which is the answer we're looking for; a transport failure is
*not* — that raises, and the caller leaves the row alone until next time."""
try:
tx = await client.get_transaction(txid, verbose=True)
except Exception as exc:
message = str(exc).lower()
if "missing" in message or "not found" in message or "no such" in message or "unknown" in message:
return False
raise
return bool(tx)
async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
"""Returns how many rows were resolved (promoted or abandoned)."""
"""Returns how many rows were resolved (promoted or abandoned).
Existence is decided by checking whether a row's own address's history
(blockchain.scripthash.get_history) includes its txid at all — mempool or
mined — rather than asking blockchain.transaction.get for a verbose reply
(B-41): several Electrum server implementations and versions reject the
verbose flag outright, and the previous substring-matching on the error
text (looking for "missing", "not found", ...) was fragile as the basis for
a decision that releases funds. A transport failure fetching history still
raises and leaves the row alone until next time — get_history not
returning our txid is the only thing that means "gone". History is cached
per scripthash within one pass, since every "payout" row shares the same
pool address.
"""
now = datetime.now(timezone.utc)
async with session_factory() as session:
rows = (
@@ -95,16 +96,25 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending")))
)
).all()
candidates = [
(row.id, row.status, row.current_txid)
for row in rows
if _is_due(row, now)
]
candidates = []
for row in rows:
if not _is_due(row, now):
continue
try:
address = await own_address_for(session, row.kind, row.user_id)
scripthash = address_to_scripthash(address)
except Exception:
logger.exception("could not derive the address for pending_transaction %s", row.id)
continue
candidates.append((row.id, row.status, row.current_txid, scripthash))
resolved = 0
for row_id, status, txid in candidates:
history_cache: dict[str, list[dict]] = {}
for row_id, status, txid, scripthash in candidates:
try:
exists = await _tx_exists_on_chain(client, txid)
if scripthash not in history_cache:
history_cache[scripthash] = await client.get_history(scripthash)
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
except Exception:
# Transport/server problem — say nothing about this tx and try again on
# the next pass rather than abandoning a tx that may be perfectly alive.