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:
+90
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user