All bets of all rounds paid one address (m/84'/746'/0'/1/0), so its history grew forever, other rounds' money and stray coins mixed with a round's own, and the payout picked from everything it held. - hd.py: derive_pool_key/derive_pool_address(round_id) on branch 2 (m/84'/746'/0'/2/<round_id>); no argument is the reserve address 1/0. derive_round_pool_key checks the key against the address stored on the round, so a payout can never be signed for money its key does not control. - rounds.pool_address (migration 477bddae1a84, existing rounds backfilled with the reserve address since that is where their bets went; add_column, not a batch alter, so the expression indexes stay): written in the transaction that inserts the round, once its id exists. - place_bet pays round.pool_address; the payout reads, signs and takes change at the round's own address; own_address_for and bump_fee's _signing_context follow the round (confirmation poller and reconciler pass round_id). - Withdrawals to any round pool address, the reserve address or fee_address are refused (withdrawal_to_platform_address, 400, all seven languages). - GET /rounds/current and GET /admin/rounds expose pool_address. - RND-22/DAT-11 stay "da implementare" for B-82: the payout still spends every confirmed coin at the round's address, not only the participants' outpoints (participants carry no bet_vout yet). - tests/unit/conftest.py: every test now gets a throwaway master key. Opening a round derives an address, and tests that merely opened a round were silently reading the real key from the local .env. - Existing tests changed because the spec changed: payout fixtures give their round a pool_address; the reconciler's address-cache test uses two payouts of one round (rows of different rounds no longer share an address); own_address_for now takes the round id. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
286 lines
12 KiB
Python
286 lines
12 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.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) -> RoundParticipant:
|
|
# 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 ---------
|
|
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).
|
|
try:
|
|
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
|
|
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: {exc}") from exc
|
|
|
|
# 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",
|
|
)
|