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
+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