from datetime import datetime, timezone from embit import script from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import ApiError from app.audit.log import write_audit_log from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.rounds.service import open_new_round_if_needed, round_accepts_bets from app.wallet.balance import recompute_balance from app.wallet.hd import derive_pool_address, derive_user_key from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction class BetError(ApiError): pass async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant: round_ = await open_new_round_if_needed(session) if round_ is None: raise BetError("no_round_open", "no round open right now, please try again shortly") config = await get_round_config(session) if not round_accepts_bets(round_, config.round_duration_seconds): raise BetError("round_closing", "the current round is closing, please try again shortly") already_playing = await session.scalar( select(RoundParticipant).where( RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id ) ) if already_playing is not None: raise BetError("already_betting", "you already have an active bet in the current round") bet_amount = config.bet_amount_sats unspent = ( await session.scalars( select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None)) ) ).all() if sum(u.amount_sats for u in unspent) < bet_amount: raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount) user_key = derive_user_key(user.derivation_index) from_script = script.p2wpkh(user_key.to_public()) utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent] try: built = build_signed_transaction( signing_key=user_key, from_script=from_script, utxos=utxos, to_address=derive_pool_address(), amount_sats=bet_amount, change_address=user.address, fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: raise BetError(exc.code, str(exc), **exc.params) from exc # --- Phase 1: record the intent, *then* broadcast (B-08) -------------------- # Broadcasting first meant a failure (or a crash) between the broadcast and the # commit left the coins irreversibly spent on-chain with no trace in the DB: no # participant, so no entry in the draw; no pending row, so no RBF and no # confirmation tracking; and the UTXOs not even marked spent, so the next bet # would try to double-spend them. Writing "building" rows first means the worst # case is a row the reconciler (app/tx/reconcile.py) can resolve either way by # asking the chain whether the tx exists. spent_by_key = {(u.txid, u.vout): u for u in unspent} for spent in built.spent_utxos: spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid await recompute_balance(session, user.id) broadcast_at = datetime.now(timezone.utc) participant = RoundParticipant( round_id=round_.id, user_id=user.id, bet_amount_sats=built.recipient_sats, bet_txid=built.txid, broadcast_at=broadcast_at, status="building", ) session.add(participant) pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb) session.add(pending) await session.commit() # --- Phase 2: broadcast, then promote both rows to their live state --------- try: await client.broadcast(built.raw_hex) except Exception as exc: # The node refused it (fee too low, dust, mempool conflict, or simply an # unreachable server) — nothing is on-chain, so undo phase 1 completely and # give the user a translatable failure instead of a bare 500 (B-07). await _release_failed_bet(session, participant, pending, built, user.id, str(exc)) raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc participant.status = "broadcast" pending.status = "pending" await write_audit_log( session, "bet_placed", {"txid": built.txid, "amount_sats": built.recipient_sats}, user_id=user.id, round_id=round_.id, ) await session.commit() await session.refresh(participant) broadcaster.publish() # participant_count/jackpot changed — nudge every dashboard to refetch return participant async def _release_failed_bet( session: AsyncSession, participant: RoundParticipant, pending: PendingTransaction, built: BuiltTransaction, user_id: int, reason: str, ) -> None: """Undo phase 1 after a failed broadcast: free the UTXOs the build reserved, drop the two rows, and restore the balance. Same shape as what the reconciler does for a tx that turns out never to have made it onto the chain.""" for spent in built.spent_utxos: row = await session.scalar( select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout) ) if row is not None: row.spent_txid = None await session.delete(participant) await session.delete(pending) await recompute_balance(session, user_id) await write_audit_log( session, "bet_broadcast_failed", {"txid": built.txid, "reason": reason[:200]}, user_id=user_id, ) await session.commit() # The rollback moved as much state as the successful path did — the balance is # back, the participant is gone, so participant_count and jackpot shrank again. # Without this the dashboards kept showing the phantom bet until their next poll # (B-49); the reconciler's own abandon path has always published here. broadcaster.publish() def _pending_transaction( round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int ) -> PendingTransaction: return PendingTransaction( kind="bet", round_id=round_id, user_id=user_id, current_txid=built.txid, fee_rate_sat_vb=fee_rate_sat_vb, raw_tx_hex=built.raw_hex, # "building" until the broadcast succeeds — see place_bet's two phases. It # matters which one this starts as: the reconciler gives a "building" row a # short grace period (we may have died mid-broadcast) and a "pending" one a # long one (a node accepted it once, so it deserves the RBF attempts first). status="building", )