Detect UTXOs spent outside the platform and correct the cached balance

credit_confirmed_utxos only ever credited new UTXOs; a UTXO spent by
something other than the app's own bet/withdrawal/payout flow (e.g. someone
using the raw derived privkey directly) never got its spent_txid set, so
cached_balance_sats kept counting it forever. detect_external_spends mirrors
the same listunspent refresh in the other direction: anything still marked
unspent in our DB but missing from the address's current unspent set gets
spent_txid="external-spend", an audit_log entry, and an immediate balance
recompute — wired into the same ElectrumListener._refresh_user call that
already runs on every scripthash notification and on listener (re)connect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 08:45:09 +02:00
co-authored by Claude Sonnet 5
parent 447bbba83e
commit f1a1145cda
3 changed files with 83 additions and 3 deletions
+46
View File
@@ -54,3 +54,49 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
broadcaster.publish() # nudges this user's dashboard to refetch its balance instantly
return newly_credited
_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).
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.
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
unspent_rows = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
)
).all()
newly_spent = 0
for row in unspent_rows:
if (row.txid, row.vout) in current_keys:
continue
row.spent_txid = _EXTERNAL_SPEND_SENTINEL
await write_audit_log(
session,
"utxo_spent_externally",
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
user_id=user_id,
)
newly_spent += 1
if newly_spent:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish()
return newly_spent