_release_failed_bet and _release_failed_withdrawal restored the balance, freed the reserved UTXOs and (for a bet) removed the participant without calling broadcaster.publish(), so every dashboard kept showing the phantom bet and the reduced balance until its next poll — while the success path and the reconciler's own abandon path both published. The two regression tests pre-open the round before subscribing: place_bet opens one itself, and that publish() would otherwise satisfy the assertion whether or not the rollback published anything.
170 lines
6.8 KiB
Python
170 lines
6.8 KiB
Python
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",
|
|
)
|