Files
plm-lottery/app/deposits/service.py
T
davideandClaude Sonnet 5 f1a1145cda 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>
2026-07-27 08:45:09 +02:00

103 lines
3.6 KiB
Python

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.models import UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
`listunspent` response (idempotent on txid+vout), refresh the user's cached
balance. Returns the number of newly-credited UTXOs.
entries: [{"tx_hash": ..., "tx_pos": ..., "height": ..., "value": ...}, ...]
height <= 0 means unconfirmed (mempool) per the Electrum protocol convention —
skipped, since the spec requires 1 confirmation before crediting.
"""
existing_keys = {
(txid, vout)
for txid, vout in (
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
).all()
}
newly_credited = 0
for entry in entries:
if entry["height"] <= 0:
continue
key = (entry["tx_hash"], entry["tx_pos"])
if key in existing_keys:
continue
session.add(
UtxoEvent(
user_id=user_id,
txid=entry["tx_hash"],
vout=entry["tx_pos"],
amount_sats=entry["value"],
confirmed_height=entry["height"],
)
)
await write_audit_log(
session,
"deposit_credited",
{"txid": entry["tx_hash"], "vout": entry["tx_pos"], "amount_sats": entry["value"]},
user_id=user_id,
)
newly_credited += 1
if newly_credited:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
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