Files
plm-lottery/app/rounds/service.py
T
davideandClaude Sonnet 5.5 fd6a91e93d Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
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>
2026-10-14 13:52:32 +02:00

164 lines
8.1 KiB
Python

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.wallet.hd import derive_pool_address
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 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 rounds_can_open(config: RoundConfig) -> bool:
"""Whether the instance is configured well enough to run a round at all (B-66).
Only fee_address today, and only because a round without one is unpayable: the
payout pays the 30% commission to it, so build_payout_transaction cannot even be
built. It has no column default for exactly this reason (rounds/config.py) — an
operator must set their own, and until they do there is nothing to guess.
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."""
return bool(config.fee_address.strip())
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,
)
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)