Files
plm-lottery/app/bets/service.py
T
davideandClaude Sonnet 5.5 2b6ddb019b Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
place_bet and request_withdrawal released everything on any exception from the
broadcast, including the 15s timeout and a connection dropped after the write. The
tx may have propagated: a bet's sats landed in the pool with no participant, a
withdrawal left while its row said failed and its coins were spendable again.

- client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply
  carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain
  ElectrumError.
- tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused /
  uncertain. A refusal is verified first: ElectrumListener.transaction_known asks
  blockchain.transaction.get of the active server and every other one (10s each) —
  any server returning the tx means accepted; the active server plus
  MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps
  the reduced rule); too few answers is uncertain. "already known" wording is only a
  shortcut that skips the check. With no network to ask, a refusal is uncertain.
- place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal
  releases; uncertain leaves the rows building for the reconciler, publishes SSE and
  POST /bets and POST /withdrawals answer 202 with status building; "already known"
  promotes a payout (payout_sent) like any accepted broadcast.
- An accepted transaction is fanned out to every other server
  (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF
  replacement in bump_fee is left for B-77, which rewrites that function.

Tests changed because the spec changed: the existing "refused broadcast" tests raised
a bare RuntimeError (now an uncertain outcome) and are made explicit refusals
(rpc_error) with a network that denies the tx; direct route calls pass a Response;
FakePayoutListener gains the network surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:22:20 +02:00

300 lines
14 KiB
Python

import logging
from datetime import datetime, timezone
from embit import script
from sqlalchemy import func, select, update
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, 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.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import (
MAX_PARTICIPANTS_PER_ROUND,
BuiltTransaction,
InsufficientFundsError,
Utxo,
build_signed_transaction,
)
logger = logging.getLogger(__name__)
class BetError(ApiError):
pass
class _RoundClosedDuringBuild(Exception):
"""Internal signal (B-53): the round stopped accepting bets while this one was
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
async def place_bet(
session: AsyncSession, client: ElectrumClient, user: User, network: TransactionNetwork | None = None
) -> RoundParticipant:
"""`network` (the ElectrumListener) is what verifies a refused broadcast and fans an
accepted one out (B-76); without it a refusal can never be proven, so it never
releases anything."""
# B-88 (DAT-74, BET-11): take SQLite's write lock before the first read, so the
# balance, the free UTXOs, the participants and the round's status read below are
# the state the phase-1 write lands on — a plain BEGIN only starts at the first
# write, leaving every read before it outside any transaction. Failing to get the
# lock within busy_timeout is not a conflict about this bet: nothing has been
# built or broadcast yet, so the player is simply asked to retry.
try:
await begin_immediate(session)
except OperationalError as exc:
raise _service_busy() from exc
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_):
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")
# B-52: "this round can always be paid out" is an invariant, and this is where it
# gets enforced — before any of this user's money moves. The payout has to spend
# one pool UTXO per bet, so a round that grew past what a single payout
# transaction may spend was unpayable: it stayed "paying_out" retrying forever,
# and because no new round may open while one is active, the whole lottery
# stopped. Refusing the bet costs the player one round of waiting; accepting it
# cost everyone the platform. Counted over every participant row, not just the
# confirmed ones: a bet that later fails frees a slot, so counting them all is
# the conservative direction.
participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
)
if participant_count >= MAX_PARTICIPANTS_PER_ROUND:
raise BetError(
"round_full",
f"this round already has its maximum of {MAX_PARTICIPANTS_PER_ROUND} players, "
"wait for the next one",
max_participants=MAX_PARTICIPANTS_PER_ROUND,
)
if not round_.pool_address:
# B-80: open_new_round_if_needed always writes it; a row without one cannot
# take money that would then have nowhere verifiable to go.
raise RuntimeError(f"round {round_.id} has no pool address")
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=round_.pool_address, # B-80: this round's own 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)
# B-53: the deadline check at the top of this function happened before the UTXO
# scan and the signing above, so re-check it here against the clock as it is now —
# a slow build must not sneak a bet past the round's deadline.
#
# And then the part the clock can't cover: a compare-and-set on the round's own
# row, in the *same* transaction as the participant insert. The scheduler flips
# "open" -> "closing" in a transaction of its own and only counts in-flight
# participants afterwards, so without this a bet could commit its "building" row
# in between and be paid into the pool while the round drew and paid out without
# it — money credited to no round, no participant and no refund path. The UPDATE
# takes SQLite's write lock, so the two transactions can no longer interleave:
# either this commits first and the scheduler's subsequent in-flight count sees
# the row, or the flip commits first and this matches zero rows and refuses the
# bet before anything is broadcast.
try:
if not round_accepts_bets(round_):
raise _RoundClosedDuringBuild
guard = await session.execute(
update(Round)
.where(Round.id == round_.id, Round.status == "open")
.values(status="open")
.execution_options(synchronize_session=False)
)
if guard.rowcount != 1:
raise _RoundClosedDuringBuild
await session.commit()
except _RoundClosedDuringBuild as exc:
# Nothing has been broadcast yet, so the rollback undoes phase 1 entirely —
# the UTXOs stay unspent and no participant row survives.
await session.rollback()
raise BetError(
"round_closing", "the current round is closing, please try again shortly"
) from exc
except OperationalError as exc:
# B-88: SQLite has no write-snapshot conflicts — an OperationalError here is
# the lock wait (busy_timeout) expiring, which says nothing about the round.
# Same rollback, but the honest answer is "busy", not "closing".
await session.rollback()
raise _service_busy() from exc
# --- Phase 2: broadcast, then promote both rows to their live state ---------
# B-76 (TX-04, TX-20): only a *verified* refusal undoes phase 1. A timeout, a
# dropped connection or a refusal nobody could verify leaves the tx possibly on the
# network, and releasing the coins then sent the bet's sats into the pool with no
# participant behind them — so those rows stay "building" for the reconciler.
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
if verdict.outcome is BroadcastOutcome.REFUSED:
# The network refused it (fee too low, dust, mempool conflict) and no server
# knows it — nothing is on-chain, so undo phase 1 completely and give the user
# a translatable failure instead of a bare 500 (B-07).
try:
await _release_failed_bet(session, participant, pending, built, user.id, verdict.reason)
except OperationalError:
# B-88: the database is busy, so the release could not be written. The
# rows stay "building" — the state phase 1 committed — and the
# reconciler resolves them against the chain.
await session.rollback()
logger.error("could not release the failed bet %s: database busy, left to the reconciler", built.txid)
raise BetError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
logger.warning("bet %s: broadcast outcome uncertain (%s), left 'building' for the reconciler", built.txid, verdict.reason)
await session.refresh(participant) # reports the status the DB really holds: building
broadcaster.publish() # an in-flight bet counts towards the round's figures
return participant
# B-88: after the broadcast nothing may be undone. If the lock cannot be had the
# rows stay "building" (the tx is on the network) and the reconciler promotes
# them; the caller still gets the participant, with the status the DB holds.
try:
await begin_immediate(session)
except OperationalError:
await session.rollback()
logger.error("bet %s broadcast but the database is busy: left 'building' for the reconciler", built.txid)
await session.refresh(participant) # reports the status the DB really holds
return participant
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."""
await begin_immediate(session) # B-88
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 _service_busy() -> BetError:
return BetError(
"service_busy",
"the service is busy right now, nothing was sent — please try again",
)
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",
)