2026-07-21 10:26:11 +02:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from embit import script
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-07-26 21:44:39 +02:00
|
|
|
from app.api.errors import ApiError
|
2026-07-21 10:26:11 +02:00
|
|
|
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
|
2026-07-23 10:52:07 +02:00
|
|
|
from app.rounds.events import broadcaster
|
2026-07-22 14:07:09 +02:00
|
|
|
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
|
2026-07-21 10:26:11 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 21:44:39 +02:00
|
|
|
class BetError(ApiError):
|
2026-07-21 10:26:11 +02:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
|
|
|
|
|
round_ = await open_new_round_if_needed(session)
|
2026-07-21 11:29:59 +02:00
|
|
|
if round_ is None:
|
2026-07-26 21:44:39 +02:00
|
|
|
raise BetError("no_round_open", "no round open right now, please try again shortly")
|
2026-07-22 14:07:09 +02:00
|
|
|
|
|
|
|
|
config = await get_round_config(session)
|
|
|
|
|
if not round_accepts_bets(round_, config.round_duration_seconds):
|
2026-07-26 21:44:39 +02:00
|
|
|
raise BetError("round_closing", "the current round is closing, please try again shortly")
|
2026-07-21 10:26:11 +02:00
|
|
|
|
|
|
|
|
already_playing = await session.scalar(
|
|
|
|
|
select(RoundParticipant).where(
|
|
|
|
|
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if already_playing is not None:
|
2026-07-26 21:44:39 +02:00
|
|
|
raise BetError("already_betting", "you already have an active bet in the current round")
|
2026-07-21 10:26:11 +02:00
|
|
|
|
|
|
|
|
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:
|
2026-07-26 21:44:39 +02:00
|
|
|
raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount)
|
2026-07-21 10:26:11 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-07-21 15:05:40 +02:00
|
|
|
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
2026-07-21 10:26:11 +02:00
|
|
|
)
|
|
|
|
|
except InsufficientFundsError as exc:
|
2026-07-27 23:30:06 +02:00
|
|
|
raise BetError(exc.code, str(exc), **exc.params) from exc
|
2026-07-21 10:26:11 +02:00
|
|
|
|
2026-07-27 00:31:24 +02:00
|
|
|
# --- 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.
|
2026-07-21 10:26:11 +02:00
|
|
|
spent_by_key = {(u.txid, u.vout): u for u in unspent}
|
|
|
|
|
for spent in built.spent_utxos:
|
2026-07-27 00:31:24 +02:00
|
|
|
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
|
2026-07-21 10:26:11 +02:00
|
|
|
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,
|
2026-07-27 00:31:24 +02:00
|
|
|
status="building",
|
2026-07-21 10:26:11 +02:00
|
|
|
)
|
|
|
|
|
session.add(participant)
|
2026-07-27 00:31:24 +02:00
|
|
|
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"
|
2026-07-21 10:26:11 +02:00
|
|
|
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)
|
2026-07-23 10:52:07 +02:00
|
|
|
broadcaster.publish() # participant_count/jackpot changed — nudge every dashboard to refetch
|
2026-07-21 10:26:11 +02:00
|
|
|
return participant
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 00:31:24 +02:00
|
|
|
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()
|
2026-07-27 23:35:10 +02:00
|
|
|
# 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()
|
2026-07-27 00:31:24 +02:00
|
|
|
|
|
|
|
|
|
2026-07-21 15:05:40 +02:00
|
|
|
def _pending_transaction(
|
|
|
|
|
round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int
|
|
|
|
|
) -> PendingTransaction:
|
2026-07-21 10:26:11 +02:00
|
|
|
return PendingTransaction(
|
|
|
|
|
kind="bet",
|
|
|
|
|
round_id=round_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
current_txid=built.txid,
|
2026-07-21 15:05:40 +02:00
|
|
|
fee_rate_sat_vb=fee_rate_sat_vb,
|
2026-07-21 10:26:11 +02:00
|
|
|
raw_tx_hex=built.raw_hex,
|
2026-07-27 00:31:24 +02:00
|
|
|
# "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",
|
2026-07-21 10:26:11 +02:00
|
|
|
)
|