import logging from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Round, RoundConfig from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.rounds.draw import new_server_seed, server_seed_commitment from app.wallet.hd import derive_pool_address from app.wallet.keystore import encrypt_round_seed from app.wallet.psbt_builder import ( DUST_LIMIT_SATS, MAX_PARTICIPANTS_PER_ROUND, MAX_TX_INPUTS, estimate_vsize, payout_bump_reserve, ) logger = logging.getLogger(__name__) _ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out") # Bounded: a conflict means someone else is opening a round right now, so a couple # of retries is plenty. Unbounded retries could spin if the invariant were ever # broken in a way we don't anticipate. _OPEN_ROUND_ATTEMPTS = 3 # 70% winner / 30% fees. Hardcoded by design (see CLAUDE.md) — changing the split # is a code change, not an admin-editable setting. Single source of truth so the # advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge. def _new_round_secret() -> dict: """B-73 (RND-34): a fresh secret for a round about to open, as the columns that hold it — its published commitment and its encrypted form, never the plaintext.""" server_seed = new_server_seed() return { "server_seed_hash": server_seed_commitment(server_seed), "server_seed_enc": encrypt_round_seed(server_seed), } def winner_share(pool_amount_sats: int) -> int: return pool_amount_sats * 70 // 100 async def get_active_round(session: AsyncSession) -> Round | None: """The round currently in progress (in any non-closed state), if any. Rounds never overlap: a new round only opens once the previous one is fully closed (payout confirmed, or no participants to pay out). The database enforces "at most one active round" (ix_rounds_single_active, see app/db/models.py), so the ordering below is belt-and-braces; if it ever does see two, that's a broken invariant and worth a loud log rather than silently picking one.""" active = ( await session.scalars(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc())) ).all() if len(active) > 1: logger.error( "invariant violated: %s rounds are active at once (ids=%s) — using the newest", len(active), [r.id for r in active], ) return active[0] if active else None def round_deadline(round_: Round) -> datetime: """When this round stops accepting bets. B-61: from the round's own snapshotted duration, not from the live config — an operator editing round_duration_seconds mid-round must not move a deadline clients are already counting down to, nor close an in-progress round on the spot.""" return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds) def round_accepts_bets(round_: Round) -> bool: """The authoritative "yellow light" check: once a round's timer has expired, no new bet may be accepted, even though its DB status is still "open" (the scheduler only flips it to "closing" on its next tick, up to _TICK_INTERVAL_SECONDS later — see rounds/scheduler.py). Bets already placed before the deadline are unaffected: the round still waits for them to confirm before actually closing.""" if round_.status != "open": return False return datetime.now(timezone.utc) < round_deadline(round_) def payout_config_problem(bet_amount_sats: int, fee_rate_sat_vb: int) -> str | None: """Why a round could not be paid out under this bet amount and fee rate, or None (B-79, RND-40). Checked for the two extremes — 1 participant (the smallest platform share) and MAX_PARTICIPANTS_PER_ROUND (the largest bump reserve, which grows with the inputs): the winner's 70% must cover the network fee of the payout and still be at least dust, and the platform's 30% must cover the bump reserve and still leave the fee address at least dust. The smallest bet that can reach the pool is the amount minus the fee of the biggest transaction a user may build (MAX_TX_INPUTS inputs, 2 outputs), so that is what every participant is assumed to have contributed.""" net_bet = bet_amount_sats - estimate_vsize(MAX_TX_INPUTS, 2) * fee_rate_sat_vb for participants in (1, MAX_PARTICIPANTS_PER_ROUND): pool = participants * net_bet share = winner_share(pool) if share - estimate_vsize(participants, 3) * fee_rate_sat_vb < DUST_LIMIT_SATS: return "winner_share_below_network_fee" if pool - share - payout_bump_reserve(participants) < DUST_LIMIT_SATS: return "commission_below_dust_limit" return None def rounds_can_open(config: RoundConfig) -> bool: """Whether the instance is configured well enough to run a round at all (B-66, B-79). Two prerequisites, both because a round without them is unpayable: a fee_address — the payout pays the platform's share to it, so build_payout_transaction cannot even be built, and it has no column default (rounds/config.py) because an operator must set their own — and a bet amount and fee rate under which a round of 1 or of MAX_PARTICIPANTS_PER_ROUND participants can cover the payout's fee, the bump reserve and dust outputs (payout_config_problem, RND-40). PUT /admin/config refuses the same combinations, so this only trips on a configuration that predates the check. Anything else that would make a round unpayable belongs here too, next to it, rather than being discovered at payout time. Deliberately not about *pausing*, which is a decision an operator took (RoundConfig.paused) rather than a prerequisite they haven't met yet.""" if not config.fee_address.strip(): return False return payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb) is None async def open_new_round_if_needed(session: AsyncSession) -> Round | None: """Returns the active round if one exists (whatever its status). Otherwise opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS) hasn't elapsed yet, the lottery is paused for maintenance, or the instance isn't configured well enough to pay a winner — in any of those cases returns None. Callers that need to attach a bet must additionally check the returned round's status == "open" — a round in closing/drawing/paying_out isn't accepting new bets, but a new round can't open until it's done. Pausing never touches a round already in progress: it only suppresses opening the *next* one, so the current round still closes, draws, and pays out the winner normally (see admin.py's /admin/pause and /admin/resume).""" active = await get_active_round(session) if active is not None: return active config = await get_round_config(session) if config.paused: return None if not rounds_can_open(config): # B-66: a fresh instance starts with no fee_address, and a round opened # without one takes bets, confirms them, and only then discovers that the # payout cannot be built — leaving the round wedged in "paying_out", # retrying every 60s, with money already in the pool. Every round would # need its own manual recovery. Refusing to open costs nothing by # comparison: no money has moved yet, and it is the operator's own missing # setup, surfaced through GET /rounds/current's lottery_configured and the # admin panel rather than discovered a round too late. return None last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc())) if last_closed is not None and last_closed.closed_at is not None: closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc) # B-61: the cooldown the closing round announced is the one honoured, so # editing the config never retroactively shortens or extends a gap already # under way. The new value applies from the next round on. if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds): return None for attempt in range(_OPEN_ROUND_ATTEMPTS): # B-61: the timing this round will run by, fixed at open time. round_ = Round( status="open", duration_seconds=config.round_duration_seconds, cooldown_seconds=config.round_cooldown_seconds, draw_confirmations=config.draw_confirmations, # B-73 (DAT-61) # B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the # plaintext column stays NULL until the reveal. **_new_round_secret(), ) session.add(round_) try: await session.flush() except IntegrityError: # Another caller (the scheduler tick, or a concurrent place_bet) got # there first — ix_rounds_single_active turns what used to be two live # rounds into a clean failure here. Roll our insert back and use theirs. # Safe to roll back: this runs before its callers have written anything # else in this session. await session.rollback() existing = await get_active_round(session) if existing is not None: logger.info("lost the race to open a round; using round %s", existing.id) return existing # Nothing active *and* the insert conflicted: the winner's transaction # hadn't committed yet when we looked. Try again rather than failing the # caller — a bet shouldn't 500 because of a scheduler tick's timing. logger.info("round-open conflict with nothing active yet (attempt %s), retrying", attempt + 1) continue # Published pre-commit (the caller commits right after) — acceptable: this # only tells subscribers "go refetch", and by the time an SSE client's # refetch request actually lands, this in-process commit (microseconds # away) has essentially always already happened. # B-80 (RND-35, DAT-60): the round's own pool address, now that its id exists — # still inside the caller's transaction, so no committed round ever lacks one. round_.pool_address = derive_pool_address(round_.id) broadcaster.publish() return round_ logger.error("could not open a round after %s attempts", _OPEN_ROUND_ATTEMPTS) return await get_active_round(session)