Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
325 lines
15 KiB
Python
325 lines
15 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.versions import delete_versions, record_version
|
|
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,
|
|
RECIPIENT_VOUT,
|
|
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,
|
|
)
|
|
|
|
# B-95 (BET-20, DAT-27): the round's own cap on the pool, when it has one. The sum counts
|
|
# every participant row (a bet still in flight may yet confirm — the conservative side), and
|
|
# a bet is refused once the sum has *reached* the cap, since its own net amount is not known
|
|
# before it is built: the pool can therefore end up above the cap by at most one bet.
|
|
if round_.max_round_pool_sats:
|
|
pool_so_far = await session.scalar(
|
|
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
|
|
RoundParticipant.round_id == round_.id
|
|
)
|
|
)
|
|
if pool_so_far >= round_.max_round_pool_sats:
|
|
raise BetError(
|
|
"round_full",
|
|
"this round has reached its limit, wait for the next one",
|
|
max_participants=MAX_PARTICIPANTS_PER_ROUND,
|
|
max_round_pool_sats=round_.max_round_pool_sats,
|
|
)
|
|
|
|
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")
|
|
|
|
# B-95 (BET-21): the stake of the round — what every player of it pays — not the value in
|
|
# round_config now, which an operator may have edited since the round opened.
|
|
bet_amount = round_.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,
|
|
bet_vout=RECIPIENT_VOUT, # B-73 (DAT-63)
|
|
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 record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
|
|
|
|
# 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 delete_versions(session, pending.id) # B-77 (DAT-38): the versions go with the row
|
|
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",
|
|
)
|