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
+4 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
from app.deposits.service import credit_confirmed_utxos, detect_external_spends
from app.electrum.client import ElectrumClient, ElectrumEndpoint
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
@@ -207,5 +207,8 @@ class ElectrumListener:
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
spent_externally = await detect_external_spends(session, user_id, entries)
if credited:
logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id)
if spent_externally:
logger.warning("%s UTXO(s) spent outside the platform for user_id=%s", spent_externally, user_id)
+33 -2
View File
@@ -1,9 +1,10 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
from app.db.models import AuditLog, User, UtxoEvent
from app.deposits.service import credit_confirmed_utxos, detect_external_spends
@pytest.fixture
@@ -52,3 +53,33 @@ async def test_idempotent_on_repeated_notification(session_factory, user_id):
assert first == 1
assert second == 0
assert user.cached_balance_sats == 7_000_000
async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_factory, user_id):
entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
async with session_factory() as session:
await credit_confirmed_utxos(session, user_id, entries)
async with session_factory() as session:
spent = await detect_external_spends(session, user_id, [])
assert spent == 1
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid == "external-spend"
audit_events = (await session.scalars(select(AuditLog))).all()
assert any(e.event_type == "utxo_spent_externally" for e in audit_events)
async def test_no_spend_detected_when_utxo_still_unspent(session_factory, user_id):
entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}]
async with session_factory() as session:
await credit_confirmed_utxos(session, user_id, entries)
async with session_factory() as session:
spent = await detect_external_spends(session, user_id, entries)
assert spent == 0
user = await session.get(User, user_id)
assert user.cached_balance_sats == 3_000_000