diff --git a/app/deposits/service.py b/app/deposits/service.py index d515d91..9c3cd34 100644 --- a/app/deposits/service.py +++ b/app/deposits/service.py @@ -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 diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 6514667..42c7c43 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -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) diff --git a/tests/unit/test_deposits.py b/tests/unit/test_deposits.py index 8571dd3..299ec9c 100644 --- a/tests/unit/test_deposits.py +++ b/tests/unit/test_deposits.py @@ -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