Corroborate an external spend before marking a UTXO gone (B-29)

detect_external_spends marked a UTXO spent_txid='external-spend'
irreversibly the moment it was missing from one listunspent reply, on
one server, with no way to undo it. A rotated-to server that's broken
or behind, or an empty reply, could zero a user's balance permanently.

Split into three functions in deposits/service.py: find_utxos_missing_
from (read-only candidate detection, and refuses to flag anything at
all when listunspent comes back entirely empty for a funded address -
that reads as a broken response, not a full sweep), mark_utxos_spent_
externally (persistence only, once a candidate is already confirmed),
and reinstate_reappeared_utxos (undoes the mark if the outpoint
reappears as unspent later).

ElectrumListener gains corroborate_utxo_spent, sharing the same
majority-quorum logic corroborate_header already uses for B-28: before
a candidate is marked, the other configured servers are asked whether
they also see it as spent. _refresh_user now reads candidates, then
corroborates each one with no DB session held open across those
network calls (same shape as B-18/B-25), then persists.

Suite grows from 165 to 176 tests. BUGS.md moves B-29 to Previously
fixed.
This commit is contained in:
2026-07-27 10:25:07 +02:00
parent 0ce0562fd7
commit e8fdea0389
5 changed files with 453 additions and 84 deletions
+90 -17
View File
@@ -1,3 +1,5 @@
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,6 +8,8 @@ from app.db.models import UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
logger = logging.getLogger(__name__)
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
@@ -59,18 +63,64 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
_EXTERNAL_SPEND_SENTINEL = "external-spend"
async def detect_external_spends(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Mirror of credit_confirmed_utxos: catches a UTXO leaving the address
through a transaction this platform never built (e.g. someone spending it
directly with the raw privkey, bypassing /withdrawals entirely).
async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""The reverse of a mark applied by find_utxos_missing_from/
mark_utxos_spent_externally (B-29): if an outpoint we'd previously flagged as
spent outside the platform reappears as unspent in a later listunspent, undo
the mark instead of leaving it permanent no matter what the chain says
afterwards. Cheap and purely DB-side — always safe to run on every refresh.
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
Everything the platform itself spends (bets, withdrawals, payouts) sets
spent_txid at broadcast time, before the tx ever reaches the chain — so by
the time an Electrum refresh runs, an outpoint still marked unspent in our
own DB that Electrum no longer reports as unspent was never on our radar.
entries is this address's current `listunspent`; anything in our unspent
set but missing from it left the address some other way. Returns the
number of UTXOs newly marked spent.
marked_rows = (
await session.scalars(
select(UtxoEvent).where(
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
)
)
).all()
reinstated = 0
for row in marked_rows:
if (row.txid, row.vout) not in current_keys:
continue
row.spent_txid = None
await write_audit_log(
session,
"utxo_external_spend_reinstated",
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
user_id=user_id,
)
reinstated += 1
if reinstated:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish()
return reinstated
async def find_utxos_missing_from(session: AsyncSession, user_id: int, entries: list[dict]) -> list[UtxoEvent]:
"""Candidates for an external spend (B-29): unspent UTXOs the DB believes this
user still holds that are absent from `entries`, this address's current
listunspent. Everything the platform itself spends (bets, withdrawals,
payouts) sets spent_txid at broadcast time, before the tx ever reaches the
chain — so an outpoint still marked unspent in our own DB that Electrum no
longer reports as unspent was never on our own radar.
Returning a row here is *not* proof it was actually spent — only that this one
server's reply no longer lists it. A single broken, behind, or malicious
server could otherwise zero a user's balance on one bad reply, which is why
the caller (electrum/listener.py:_refresh_user) must independently
corroborate each candidate against other configured servers before treating
it as genuine, rather than this function marking anything itself.
An entirely empty `entries` for an address the DB believes is funded returns
no candidates at all: it would otherwise flag every one of this user's UTXOs
as missing from a single reply, which is a strong sign of an incomplete or
broken response rather than N independent spends landing in the same refresh.
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
@@ -80,9 +130,32 @@ async def detect_external_spends(session: AsyncSession, user_id: int, entries: l
)
).all()
newly_spent = 0
for row in unspent_rows:
if (row.txid, row.vout) in current_keys:
if not entries and unspent_rows:
logger.warning(
"listunspent for user_id=%s returned no entries at all while %s UTXO(s) are still recorded "
"unspent — treating this as an incomplete response rather than a full external sweep",
user_id,
len(unspent_rows),
)
return []
return [row for row in unspent_rows if (row.txid, row.vout) not in current_keys]
async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_ids: list[int]) -> int:
"""Applies the external-spend sentinel to UTXOs the caller has already
corroborated against other servers (B-29) — this function does no
verification of its own, only persistence, so it never runs with a session
held open across the network calls that verification needs.
Re-checks each row is still unspent before applying the mark: something else
may have resolved it (a legitimate platform spend, or a prior refresh) between
when the caller read the candidate list and finished corroborating it.
"""
marked = 0
for utxo_id in utxo_ids:
row = await session.get(UtxoEvent, utxo_id)
if row is None or row.spent_txid is not None:
continue
row.spent_txid = _EXTERNAL_SPEND_SENTINEL
await write_audit_log(
@@ -91,12 +164,12 @@ async def detect_external_spends(session: AsyncSession, user_id: int, entries: l
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
user_id=user_id,
)
newly_spent += 1
marked += 1
if newly_spent:
if marked:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish()
return newly_spent
return marked