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
+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: