Snapshot a round's timing when it opens (B-61)

round_duration_seconds was read live on every scheduler tick and every bet
check, with the deadline computed as opened_at + duration. Lowering it from 600
to 60 while a round was 300s in closed that round instantly; raising it moved
the closes_at clients were already counting down to. round_cooldown_seconds had
the same property for the gap after a close. B-11 fixed this class of problem
for the advertised jackpot; the timing fields were left live.

Round now carries duration_seconds and cooldown_seconds, set from the config
when it opens. round_deadline() is the single place the deadline is computed —
the scheduler, place_bet's two checks and /rounds/current's closes_at all go
through it — and the cooldown is read off the round that just closed, so the gap
a round announced is the gap that's honoured. The config row becomes what the
*next* round opens with.

The migration backfills from the live config rather than leaving the column
defaults: an instance running 300s rounds would otherwise see the round
currently in progress jump to 600s the moment this lands, which is precisely the
retroactive change being fixed. Verified against a scratch DB with a non-default
config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 23:25:56 +02:00
co-authored by Claude Opus 5
parent 77e07e87dc
commit 37cc5eeeb5
11 changed files with 169 additions and 37 deletions
-18
View File
@@ -40,24 +40,6 @@ remains the last prerequisite for running unattended.
## Medium — correctness and robustness
### B-61 — config edits apply retroactively to the round already in progress
`app/rounds/scheduler.py:71`, `app/rounds/service.py:51-61`,
`app/api/routes/admin.py:106-128`.
`round_duration_seconds` is read live on every tick and on every bet check, and the
deadline is computed as `opened_at + duration`. Lowering it from 600 to 60 while a
round is 300 s in closes that round instantly; raising it moves the `closes_at`
clients are already counting down to. `round_cooldown_seconds` has the same
property for the gap after a close.
B-11 fixed exactly this class of problem for `bet_amount_sats` (an in-progress
round's advertised jackpot must not move when an operator edits the bet amount);
the timing fields were left live.
Fix: snapshot the duration (and cooldown) onto the `Round` row when it opens and
read them from there, leaving the config row as the value for the *next* round.
### B-62 — "withdraw the full amount" reliably produces an unbumpable transaction
`app/static/app.js:798-807`, `app/wallet/psbt_builder.py:138-141`,
+4 -4
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 305 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 309 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
@@ -33,7 +33,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 305 tests
python -m pytest # all 309 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
@@ -84,7 +84,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|---|---|---|
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit |
| Round duration / cooldown | 600s / 30s | `RoundConfig` |
| Round duration / cooldown | 600s / 30s | `RoundConfig`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress |
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` |
| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` |
@@ -155,7 +155,7 @@ Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifec
**PLAY** — fixed cost, **at most one active bet per user**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
**DRAW** — configurable timer (default 600s):
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open``closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open``closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
+4 -2
View File
@@ -15,7 +15,7 @@ from app.db.models import RoundParticipant, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
from app.rounds.service import get_active_round, winner_share
from app.rounds.service import get_active_round, round_deadline, winner_share
router = APIRouter(prefix="/rounds", tags=["rounds"])
@@ -142,7 +142,9 @@ async def current_round(
)
) or 0
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
# B-61: from the round's own duration — the countdown clients are watching must
# not jump because an operator edited the config mid-round.
closes_at = round_deadline(round_)
# Lets the frontend show the personalized win/lose reveal only to players in
# this round — everyone else (not logged in, or logged in but didn't bet)
+2 -2
View File
@@ -38,7 +38,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
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):
if not round_accepts_bets(round_):
raise BetError("round_closing", "the current round is closing, please try again shortly")
already_playing = await session.scalar(
@@ -137,7 +137,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
# 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_, config.round_duration_seconds):
if not round_accepts_bets(round_):
raise _RoundClosedDuringBuild
guard = await session.execute(
update(Round)
+10
View File
@@ -82,6 +82,16 @@ class Round(Base):
status: Mapped[str] = mapped_column(String(16), default="open")
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
closed_at: Mapped[datetime | None] = mapped_column(default=None)
# B-61: the round's own timing, snapshotted from RoundConfig when it opens.
# Read live from the config, a mid-round edit applied retroactively: lowering
# round_duration_seconds from 600 to 60 while a round was 300s in closed it
# instantly, and raising it moved the closes_at every client was already
# counting down to. Same class of bug B-11 fixed for the advertised jackpot.
# The config row is now what the *next* round opens with; these are what this
# round runs by. cooldown_seconds is read off the round that just closed, so
# the gap it announced is the gap that's honoured.
duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600")
cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30")
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
# (draw_waiting_since) measure how long a round has been waiting on a block,
+7 -5
View File
@@ -14,7 +14,7 @@ from app.electrum.scripthash import address_to_scripthash
from app.rounds.config import get_round_config
from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, winner_share
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.wallet.hd import derive_pool_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
@@ -67,8 +67,11 @@ class RoundScheduler:
await session.commit()
if round_ is None:
return # still in the cooldown window after the last round closed
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
round_id, status = round_.id, round_.status
# B-61: this round's own snapshotted deadline, not one recomputed from
# whatever the config says now — an operator lowering the duration
# mid-round used to close the round on the spot.
deadline = round_deadline(round_)
if status == "paying_out":
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
@@ -83,8 +86,7 @@ class RoundScheduler:
return # "drawing" — progress happens inside the in-flight _close_and_draw call
if status == "open":
opened_at = opened_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds):
if datetime.now(timezone.utc) < deadline:
return
async with self._session_factory() as session:
+20 -5
View File
@@ -48,7 +48,15 @@ async def get_active_round(session: AsyncSession) -> Round | None:
return active[0] if active else None
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
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
@@ -57,8 +65,7 @@ def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
before actually closing."""
if round_.status != "open":
return False
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds)
return datetime.now(timezone.utc) < round_deadline(round_)
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
@@ -83,11 +90,19 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | 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)
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
# 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):
round_ = Round(status="open")
# 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()
+8
View File
@@ -40,6 +40,14 @@ business — quelli si toccano solo da qui.
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). |
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
Durata e cooldown si applicano **dal round successivo**, non a quello già in
corso: ogni round si porta dietro i valori con cui è stato aperto, così
abbassare la durata mentre un round è a metà non lo chiude di colpo, e alzarla
non sposta il countdown che i giocatori stanno già guardando. Gli altri
parametri (bet amount, fee rate, RBF timeout) restano invece a effetto
immediato.
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi confermati in sequenza (ultima bet in sospeso, estrazione, payout — ~2 minuti l'uno), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
@@ -0,0 +1,42 @@
"""snapshot round timing onto the round row (B-61)
Revision ID: 283844a44b4a
Revises: c1d4a97b5e10
Create Date: 2026-08-03 23:05:04.996492
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '283844a44b4a'
down_revision: Union[str, Sequence[str], None] = 'c1d4a97b5e10'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('rounds', sa.Column('duration_seconds', sa.Integer(), server_default='600', nullable=False))
op.add_column('rounds', sa.Column('cooldown_seconds', sa.Integer(), server_default='30', nullable=False))
# Backfill from the live config rather than leaving the column defaults: an
# instance running with, say, a 300s round would otherwise see every existing
# row — including the round currently in progress — jump to 600s the moment
# this migration lands, which is exactly the retroactive change B-61 is about.
op.execute(
"UPDATE rounds SET "
"duration_seconds = coalesce((SELECT round_duration_seconds FROM round_config LIMIT 1), 600), "
"cooldown_seconds = coalesce((SELECT round_cooldown_seconds FROM round_config LIMIT 1), 30)"
)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('rounds', 'cooldown_seconds')
op.drop_column('rounds', 'duration_seconds')
# ### end Alembic commands ###
+47
View File
@@ -171,3 +171,50 @@ async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
async with session_factory() as session:
assert len((await session.scalars(select(Round))).all()) == 3
# --- B-61: a round runs by the timing it opened with, not by the live config ------
async def test_a_new_round_snapshots_the_current_config_timing(session_factory):
async with session_factory() as session:
session.add(RoundConfig(fee_address="", round_duration_seconds=120, round_cooldown_seconds=45))
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
assert round_.duration_seconds == 120
assert round_.cooldown_seconds == 45
async def test_round_accepts_bets_uses_the_rounds_own_duration(session_factory):
from app.rounds.service import round_accepts_bets
opened_at = datetime.now(timezone.utc) - timedelta(seconds=100)
still_open = Round(status="open", opened_at=opened_at, duration_seconds=600)
expired = Round(status="open", opened_at=opened_at, duration_seconds=60)
assert round_accepts_bets(still_open) is True
assert round_accepts_bets(expired) is False
async def test_cooldown_comes_from_the_round_that_closed(session_factory):
"""The gap a closing round announced is the gap that's honoured: shortening
round_cooldown_seconds afterwards must not open the next round early, nor
lengthening it hold the lottery shut."""
async with session_factory() as session:
session.add(RoundConfig(fee_address="", round_cooldown_seconds=0)) # just lowered to 0
session.add(
Round(
status="closed",
opened_at=datetime.now(timezone.utc) - timedelta(seconds=200),
closed_at=datetime.now(timezone.utc) - timedelta(seconds=10),
cooldown_seconds=300, # what that round ran with
)
)
await session.commit()
async with session_factory() as session:
assert await open_new_round_if_needed(session) is None # still cooling down
+25 -1
View File
@@ -46,7 +46,8 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory,
past = datetime.now(timezone.utc) - timedelta(seconds=10)
async with session_factory() as session:
session.add(RoundConfig(fee_address="", round_duration_seconds=1))
session.add(Round(status="open", opened_at=past))
# B-61: the deadline comes from the round's own snapshot, not from the config.
session.add(Round(status="open", opened_at=past, duration_seconds=1))
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
@@ -528,3 +529,26 @@ async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(sess
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "round_closed" not in events
assert "winner_drawn" not in events
async def test_tick_ignores_a_config_duration_edited_mid_round(session_factory): # B-61
"""Lowering round_duration_seconds from 600 to 30 while a round is 300s in used
to close that round on the spot, because the deadline was recomputed live from
the config on every tick. The edit applies to the *next* round."""
async with session_factory() as session:
session.add(RoundConfig(fee_address="", round_duration_seconds=30)) # just lowered
session.add(
Round(
status="open",
opened_at=datetime.now(timezone.utc) - timedelta(seconds=300),
duration_seconds=600, # what this round opened with
)
)
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
await scheduler._tick()
async with session_factory() as session:
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "open" # still 300s to go, by its own clock