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
+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.