from datetime import datetime, timezone from embit import script from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.service import open_new_round_if_needed 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(Exception): 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 right now, please try again shortly") if round_.status != "open": raise BetError("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("you already have an active bet in the current round") config = await get_round_config(session) 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") 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(str(exc)) from exc await client.broadcast(built.raw_hex) spent_by_key = {(u.txid, u.vout): u for u in unspent} for spent in built.spent_utxos: row = spent_by_key[(spent.txid, spent.vout)] row.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="broadcast", ) session.add(participant) session.add(_pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)) 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) return participant 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, status="pending", )