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:
2026-07-22 14:07:09 +02:00
co-authored by Claude Sonnet 5
parent f27fe6243c
commit 7dcf6d2756
8 changed files with 87 additions and 17 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ The flow is organized into 5 phases, each a subgraph in [flowchart.mmd](flowchar
- **REG (Registration)**: on signup the server derives a new P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from a master xprv **encrypted at rest**. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals.
- **DEP (Balance top-up)**: an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after **1 confirmation only** — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic.
- **PLAY (Bet)**: fixed cost per round, **at most one active bet per user at a time** in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a **change output back to the same user address** (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), **deducted from the bet amount**. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast.
- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it `place_bet` checks `status == "open"` and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary). The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a "drawing" animation on every user's dashboard for at least `draw_animation_seconds` (admin-configurable, default 20s) once the round starts closing — purely cosmetic, decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set; see `GET /rounds/current`'s `winner_user_id`/`winner_amount_sats` and `app/static/index.html`'s reveal logic.
- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a "drawing" animation on every user's dashboard for at least `draw_animation_seconds` (admin-configurable, default 20s) once the round starts closing — purely cosmetic, decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set; see `GET /rounds/current`'s `winner_user_id`/`winner_amount_sats` and `app/static/index.html`'s reveal logic.
- **WITHDRAW (Withdrawal)**: the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern.
PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address.
+4 -1
View File
@@ -1,4 +1,4 @@
from datetime import timedelta, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
@@ -14,6 +14,7 @@ router = APIRouter(prefix="/rounds", tags=["rounds"])
class CurrentRoundResponse(BaseModel):
server_time: str
round_id: int | None = None
status: str | None = None
opened_at: str | None = None
@@ -37,6 +38,7 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se
if round_ is None:
await session.commit()
return CurrentRoundResponse(
server_time=datetime.now(timezone.utc).isoformat(),
bet_amount_sats=config.bet_amount_sats,
draw_animation_seconds=config.draw_animation_seconds,
chain_tip_height=chain_tip_height,
@@ -51,6 +53,7 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se
await session.commit()
return CurrentRoundResponse(
server_time=datetime.now(timezone.utc).isoformat(),
round_id=round_.id,
status=round_.status,
opened_at=opened_at.isoformat(),
+4 -3
View File
@@ -8,7 +8,7 @@ from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.service import open_new_round_if_needed
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
@@ -22,7 +22,9 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
round_ = await open_new_round_if_needed(session)
if round_ is None:
raise BetError("no round open right now, please try again shortly")
if round_.status != "open":
config = await get_round_config(session)
if not round_accepts_bets(round_, config.round_duration_seconds):
raise BetError("the current round is closing, please try again shortly")
already_playing = await session.scalar(
@@ -33,7 +35,6 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
if already_playing is not None:
raise BetError("you already have an active bet in the current round")
config = await get_round_config(session)
bet_amount = config.bet_amount_sats
unspent = (
+13
View File
@@ -16,6 +16,19 @@ async def get_active_round(session: AsyncSession) -> Round | None:
return await session.scalar(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> 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
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds)
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)
+21 -2
View File
@@ -544,6 +544,14 @@ function switchPanel(name) {
}
let roundCloseAt = null;
let serverTimeOffsetMs = 0; // serverNow - clientNow, so every client's countdown agrees regardless of local clock skew
function serverNow() { return new Date(Date.now() + serverTimeOffsetMs); }
// refreshRound() is triggered from several independent sources (poll timer, timer-hits-zero,
// visibilitychange, placeBet, showDashboard) whose requests can resolve out of order over the
// network. Track the latest applied response so a slow, stale one can never revert the UI to an
// older round's state after a newer response has already moved it forward.
let roundRequestSeq = 0;
let roundAppliedSeq = 0;
let roundTimerInterval = null;
let roundPollTimeout = null;
@@ -649,7 +657,7 @@ let timerHitZero = false;
function updateRoundTimer() {
const el = document.getElementById('round-timer');
if (!roundCloseAt) { el.textContent = '--:--'; timerHitZero = false; return; }
const rawSec = Math.floor((roundCloseAt - new Date()) / 1000);
const rawSec = Math.floor((roundCloseAt - serverNow()) / 1000);
const totalSec = Math.max(0, rawSec);
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
const ss = String(totalSec % 60).padStart(2, '0');
@@ -691,8 +699,11 @@ function showNormalState() {
}
async function refreshRound() {
const seq = ++roundRequestSeq;
try {
const data = await call('GET', '/rounds/current');
if (seq < roundAppliedSeq) return; // a newer refreshRound() call already applied its result
roundAppliedSeq = seq;
noteFetchOutcome(true);
updateChainStatusBar(data);
document.getElementById('round-title').textContent = data.round_id
@@ -708,6 +719,7 @@ async function refreshRound() {
jackpotEl.classList.add('jackpot-bump');
}
lastJackpotValue = jackpotValue;
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
updateRoundTimer();
@@ -735,6 +747,13 @@ async function refreshRound() {
} else if (data.round_id && data.round_id !== activeResultRoundId) {
// a genuinely new round is open — clear any previous result and go back to normal
activeResultRoundId = null;
// drop bookkeeping for old rounds so these maps don't grow for the life of the session
for (const key of Object.keys(drawStartedAt)) {
if (Number(key) !== data.round_id) delete drawStartedAt[key];
}
for (const id of revealedRounds) {
if (id !== data.round_id) revealedRounds.delete(id);
}
showNormalState();
} else if (!data.round_id && activeResultRoundId == null) {
// nothing has ever been revealed and there's no active round — plain empty state
@@ -747,7 +766,7 @@ async function refreshRound() {
// The countdown reaching zero doesn't mean the server has actually closed the
// round yet (it still waits for in-flight bets to confirm) — poll faster
// through that gap too, not just once status flips to closing/drawing/paying_out.
const pastDeadline = data.status === 'open' && roundCloseAt !== null && roundCloseAt - new Date() <= 0;
const pastDeadline = data.status === 'open' && roundCloseAt !== null && roundCloseAt - serverNow() <= 0;
scheduleNextRoundPoll(isDrawing || pastDeadline);
} catch (e) {
noteFetchOutcome(false);
+1 -1
View File
@@ -38,7 +38,7 @@ business — quelli si toccano solo da qui.
|---|---|
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: i payout **non partono** se questo campo è vuoto. |
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. |
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. |
| **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 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: l'estrazione reale aspetta un blocco confermato (~2 minuti in media), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
| **Importo minimo deposito/prelievo (PLM)** | Soglia minima per un prelievo (i depositi non hanno un controllo minimo lato server, solo un floor consigliato). |
+13 -8
View File
@@ -35,14 +35,19 @@ Dopo l'accesso vedi, in ordine:
### Estrazione del vincitore
Quando il round chiude, **le bet non sono più accettate** e la card del
round mostra un'animazione ("Estrazione del vincitore in corso…") al posto
del timer — la stessa cosa compare nella dashboard di ogni giocatore, non
solo la tua. L'animazione resta visibile per almeno un tempo minimo
configurabile dall'admin (default 20s), ma può durare più a lungo: il
vincitore viene scelto usando l'hash del primo blocco confermato dopo la
chiusura, quindi il tempo reale dipende dalla rete (mediamente ~2 minuti,
il block time di PLM).
Appena il timer arriva a zero, **nessun nuovo giocatore può più entrare nel
round** — è un "semaforo giallo": il conteggio raggiunto lo zero blocca da
subito le nuove giocate, ma il round non chiude immediatamente. Se qualcuno
aveva già piazzato una bet negli ultimi istanti (transazione trasmessa ma
non ancora confermata), il round aspetta che anche quella si confermi prima
di procedere, così nessuna giocata già fatta viene persa al confine del
round. Solo a quel punto la card mostra un'animazione ("Estrazione del
vincitore in corso…") al posto del timer — la stessa cosa compare nella
dashboard di ogni giocatore, non solo la tua. L'animazione resta visibile
per almeno un tempo minimo configurabile dall'admin (default 20s), ma può
durare più a lungo: il vincitore viene scelto usando l'hash del primo
blocco confermato dopo la chiusura, quindi il tempo reale dipende dalla
rete (mediamente ~2 minuti, il block time di PLM).
Appena il vincitore è determinato, l'animazione lascia spazio a un
messaggio:
+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