Distinguish round-closing sub-phases and anchor cooldown to payout confirmation

Flip status to "closing" the instant the round timer expires, rather than
leaving it "open" (invisible) while in-flight bets confirm — /rounds/current
now surfaces this wait as its own phase instead of collapsing it into
"drawing". _tick() is updated to keep re-checking pending bets while status
is "closing" instead of short-circuiting on the old "status != open" guard.

Also re-stamp closed_at at actual payout confirmation time (not at the
earlier "closing" transition), since round_cooldown_seconds counts from
closed_at — with a short cooldown (e.g. 20s) and ~2 block-time draw+payout
wait, the old anchor meant the cooldown had already elapsed by the time the
round actually closed, making it a no-op.

Expose draw_block_height/draw_block_hash on GET /rounds/current so clients
can show which block the winner was drawn from.
This commit is contained in:
2026-07-22 23:20:55 +02:00
parent 3d6f4a98c4
commit 877a219aa4
3 changed files with 29 additions and 12 deletions
+4
View File
@@ -25,6 +25,8 @@ class CurrentRoundResponse(BaseModel):
draw_animation_seconds: int draw_animation_seconds: int
winner_user_id: int | None = None winner_user_id: int | None = None
winner_amount_sats: int | None = None winner_amount_sats: int | None = None
draw_block_height: int | None = None
draw_block_hash: str | None = None
chain_tip_height: int | None = None chain_tip_height: int | None = None
lottery_paused: bool = False lottery_paused: bool = False
@@ -70,6 +72,8 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se
draw_animation_seconds=config.draw_animation_seconds, draw_animation_seconds=config.draw_animation_seconds,
winner_user_id=round_.winner_user_id, winner_user_id=round_.winner_user_id,
winner_amount_sats=round_.winner_amount_sats, winner_amount_sats=round_.winner_amount_sats,
draw_block_height=round_.draw_block_height,
draw_block_hash=round_.draw_block_hash,
chain_tip_height=chain_tip_height, chain_tip_height=chain_tip_height,
lottery_paused=config.paused, lottery_paused=config.paused,
) )
+8
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,6 +11,12 @@ async def _on_payout_confirmed(session: AsyncSession, pending: PendingTransactio
round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid)) round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid))
if round_ is not None and round_.status == "paying_out": if round_ is not None and round_.status == "paying_out":
round_.status = "closed" round_.status = "closed"
# closed_at is what round_cooldown_seconds counts from (service.py's
# open_new_round_if_needed) — re-stamp it here at actual payout
# confirmation time rather than leaving it at the earlier "closing"
# timestamp, so a short cooldown (e.g. 20s) is a real pause after the
# winner's tx confirms, not swallowed by the ~2 block-time draw+payout wait.
round_.closed_at = datetime.now(timezone.utc)
# The winner's own address is already watched by the Electrum listener, so # The winner's own address is already watched by the Electrum listener, so
# their balance is credited by the normal deposit path once this confirms. # their balance is credited by the normal deposit path once this confirms.
+17 -12
View File
@@ -54,12 +54,23 @@ class RoundScheduler:
round_id, status, opened_at = round_.id, round_.status, round_.opened_at round_id, status, opened_at = round_.id, round_.status, round_.opened_at
round_duration_seconds = (await get_round_config(session)).round_duration_seconds round_duration_seconds = (await get_round_config(session)).round_duration_seconds
if status != "open": if status not in ("open", "closing"):
return # already closing/drawing/paying_out; progress happens elsewhere return # already drawing/paying_out; progress happens elsewhere
opened_at = opened_at.replace(tzinfo=timezone.utc) if status == "open":
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds): opened_at = opened_at.replace(tzinfo=timezone.utc)
return if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds):
return
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
# No new bets from here on, regardless of how long the pending-bet
# wait below takes — flip to "closing" immediately so it's observable
# via /rounds/current (e.g. "round closed, waiting for jackpot
# confirmation") instead of silently staying "open" past the deadline.
round_.status = "closing"
round_.closed_at = datetime.now(timezone.utc)
await session.commit()
async with self._session_factory() as session: async with self._session_factory() as session:
pending_count = await session.scalar( pending_count = await session.scalar(
@@ -68,19 +79,13 @@ class RoundScheduler:
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "broadcast") .where(RoundParticipant.round_id == round_id, RoundParticipant.status == "broadcast")
) )
if pending_count: if pending_count:
return # wait for in-flight bets to confirm before closing return # wait for in-flight bets to confirm before closing; stays "closing"
await self._close_and_draw(round_id) await self._close_and_draw(round_id)
async def _close_and_draw(self, round_id: int) -> None: async def _close_and_draw(self, round_id: int) -> None:
async with self._session_factory() as session: async with self._session_factory() as session:
round_ = await session.get(Round, round_id) round_ = await session.get(Round, round_id)
round_.status = "closing"
round_.closed_at = datetime.now(timezone.utc)
# Committed on its own so "closing" is actually observable (e.g. by
# /rounds/current) instead of being overwritten in memory by "closed"
# or "drawing" below before ever reaching the database.
await session.commit()
participants = ( participants = (
await session.scalars( await session.scalars(