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