Sync round countdown across clients and enforce the bet cutoff on deadline, not scheduler tick

The round timer relied on each client's own wall clock, so two browsers with
skewed local clocks showed different countdowns for the same round; the
server now also returns server_time so the frontend can correct for clock
skew. Also drop out-of-order /rounds/current responses (multiple independent
triggers could resolve late and revert the UI to a stale drawing/result
state) and prune per-round bookkeeping maps on round transitions.

Separately, place_bet only checked status == "open", leaving a window (up to
the scheduler's 5s tick interval) after a round's timer hit zero where a new
bet could still be accepted. place_bet now checks the round's own deadline
directly (round_accepts_bets), acting as an immediate "yellow light" for new
entries while still letting already-broadcast bets confirm before the round
closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-07-22 14:07:09 +02:00
1 parent f27fe6243c
commit 7dcf6d2756
8 files changed
+87 -17

No files matched your search

+30 -1
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@@ -5,7 +7,8 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import BetError, place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
from app.rounds.service import open_new_round_if_needed
from app.wallet.hd import derive_user_address
@@ -104,3 +107,29 @@ async def test_place_bet_rejects_second_bet_same_round(session_factory):
async with session_factory() as session:
participants = (await session.scalars(select(RoundParticipant))).all()
assert len(participants) == 1
async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_factory):
"""The scheduler only flips status "open" -> "closing" on its next tick (up
to a few seconds late) — place_bet must independently refuse bets once the
round's own deadline has passed, so no new player can sneak in during that
gap (see rounds/service.round_accepts_bets)."""
user_id = await _make_funded_user(session_factory, 3, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
session.add(RoundConfig(fee_address="", round_duration_seconds=60))
round_ = await open_new_round_if_needed(session)
round_.opened_at = datetime.now(timezone.utc) - timedelta(seconds=61)
await session.commit()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="closing"):
await place_bet(session, client, user)
async with session_factory() as session:
participants = (await session.scalars(select(RoundParticipant))).all()
assert len(participants) == 0
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "open" # scheduler hasn't ticked — status is unchanged, only the check is deadline-aware