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:
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user