The payout has to spend one pool UTXO per bet, so reusing MAX_TX_INPUTS (50) for it made any round past ~50 players unpayable: select_utxos raised too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and since no new round may open while one is active, the whole lottery stopped with the pool stuck. The cap was being enforced on the payout side, i.e. discovered once the money was already committed and there was no way back. Two halves: - select_utxos takes the cap as a parameter. Bets and withdrawals keep MAX_TX_INPUTS = 50, which protects a user from a fee that eats into the amount they are moving; the payout uses MAX_PAYOUT_TX_INPUTS = 500, where that argument doesn't apply — 400 inputs at 1 sat/vB cost ~0.00027 PLM out of the winner's 70% share. What actually bounds it is relay policy: 500 inputs is ~34 kvB against the 100 kvB standardness limit, and signing that many measures ~0.4s, once per round, inside a background task. - place_bet refuses the 401st bet with a new round_full error (translated into all 7 languages), so "a round can always be paid out" is an invariant checked before any money moves. MAX_PARTICIPANTS_PER_ROUND sits below the input cap to leave the payout headroom for pool change from earlier rounds, and counts every participant row rather than only confirmed ones, since a failed bet frees a slot. A round already wedged past the old cap now pays out on the next retry tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
8.0 KiB
Python
196 lines
8.0 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from embit import script
|
|
from sqlalchemy import func, 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 (
|
|
MAX_PARTICIPANTS_PER_ROUND,
|
|
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")
|
|
|
|
# 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,
|
|
)
|
|
|
|
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",
|
|
)
|