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, User, UtxoEvent, Withdrawal from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.wallet.address import is_valid_plm_address from app.wallet.balance import recompute_balance from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction class WithdrawalError(ApiError): pass async def request_withdrawal( session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int ) -> Withdrawal: # Checked before anything else: an address from another chain parses fine as a # witness program (see wallet/address.py), so without this the tx would build, # broadcast and be irrecoverable rather than fail. if not is_valid_plm_address(external_address): raise WithdrawalError("invalid_address", "not a valid PLM bech32 address") config = await get_round_config(session) if amount_sats < config.bet_amount_sats: raise WithdrawalError( "amount_below_minimum", f"amount below the minimum of {config.bet_amount_sats} sats", minimum_sats=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) < amount_sats: raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats) 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=external_address, amount_sats=amount_sats, change_address=user.address, fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: raise WithdrawalError(exc.code, 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: spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid await recompute_balance(session, user.id) withdrawal = Withdrawal( user_id=user.id, external_address=external_address, amount_requested_sats=amount_sats, amount_sent_sats=built.recipient_sats, txid=built.txid, status="broadcast", ) session.add(withdrawal) await session.flush() session.add( PendingTransaction( kind="withdrawal", withdrawal_id=withdrawal.id, user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=config.fee_rate_sat_vb, raw_tx_hex=built.raw_hex, status="pending", ) ) await write_audit_log( session, "withdrawal_sent", {"txid": built.txid, "amount_sent_sats": built.recipient_sats, "external_address": external_address}, user_id=user.id, ) await session.commit() await session.refresh(withdrawal) broadcaster.publish() # balance just went "pending" — nudge the dashboard to refetch return withdrawal