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:
@@ -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
|
broadcaster.publish() # nudges this user's dashboard to refetch its balance instantly
|
||||||
|
|
||||||
return newly_credited
|
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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
from app.db.models import User
|
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.client import ElectrumClient, ElectrumEndpoint
|
||||||
from app.electrum.scripthash import address_to_scripthash
|
from app.electrum.scripthash import address_to_scripthash
|
||||||
from app.rounds.events import broadcaster
|
from app.rounds.events import broadcaster
|
||||||
@@ -207,5 +207,8 @@ class ElectrumListener:
|
|||||||
entries = await self.client.listunspent(scripthash)
|
entries = await self.client.listunspent(scripthash)
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
credited = await credit_confirmed_utxos(session, user_id, entries)
|
credited = await credit_confirmed_utxos(session, user_id, entries)
|
||||||
|
spent_externally = await detect_external_spends(session, user_id, entries)
|
||||||
if credited:
|
if credited:
|
||||||
logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id)
|
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)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.models import User
|
from app.db.models import AuditLog, User, UtxoEvent
|
||||||
from app.deposits.service import credit_confirmed_utxos
|
from app.deposits.service import credit_confirmed_utxos, detect_external_spends
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -52,3 +53,33 @@ async def test_idempotent_on_repeated_notification(session_factory, user_id):
|
|||||||
assert first == 1
|
assert first == 1
|
||||||
assert second == 0
|
assert second == 0
|
||||||
assert user.cached_balance_sats == 7_000_000
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user