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:
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -14,6 +14,7 @@ router = APIRouter(prefix="/rounds", tags=["rounds"])
|
|||||||
|
|
||||||
|
|
||||||
class CurrentRoundResponse(BaseModel):
|
class CurrentRoundResponse(BaseModel):
|
||||||
|
server_time: str
|
||||||
round_id: int | None = None
|
round_id: int | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
opened_at: 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:
|
if round_ is None:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return CurrentRoundResponse(
|
return CurrentRoundResponse(
|
||||||
|
server_time=datetime.now(timezone.utc).isoformat(),
|
||||||
bet_amount_sats=config.bet_amount_sats,
|
bet_amount_sats=config.bet_amount_sats,
|
||||||
draw_animation_seconds=config.draw_animation_seconds,
|
draw_animation_seconds=config.draw_animation_seconds,
|
||||||
chain_tip_height=chain_tip_height,
|
chain_tip_height=chain_tip_height,
|
||||||
@@ -51,6 +53,7 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return CurrentRoundResponse(
|
return CurrentRoundResponse(
|
||||||
|
server_time=datetime.now(timezone.utc).isoformat(),
|
||||||
round_id=round_.id,
|
round_id=round_.id,
|
||||||
status=round_.status,
|
status=round_.status,
|
||||||
opened_at=opened_at.isoformat(),
|
opened_at=opened_at.isoformat(),
|
||||||
|
|||||||
+4
-3
@@ -8,7 +8,7 @@ from app.audit.log import write_audit_log
|
|||||||
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
||||||
from app.electrum.client import ElectrumClient
|
from app.electrum.client import ElectrumClient
|
||||||
from app.rounds.config import get_round_config
|
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.balance import recompute_balance
|
||||||
from app.wallet.hd import derive_pool_address, derive_user_key
|
from app.wallet.hd import derive_pool_address, derive_user_key
|
||||||
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
|
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)
|
round_ = await open_new_round_if_needed(session)
|
||||||
if round_ is None:
|
if round_ is None:
|
||||||
raise BetError("no round open right now, please try again shortly")
|
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")
|
raise BetError("the current round is closing, please try again shortly")
|
||||||
|
|
||||||
already_playing = await session.scalar(
|
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:
|
if already_playing is not None:
|
||||||
raise BetError("you already have an active bet in the current round")
|
raise BetError("you already have an active bet in the current round")
|
||||||
|
|
||||||
config = await get_round_config(session)
|
|
||||||
bet_amount = config.bet_amount_sats
|
bet_amount = config.bet_amount_sats
|
||||||
|
|
||||||
unspent = (
|
unspent = (
|
||||||
|
|||||||
@@ -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()))
|
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:
|
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||||
"""Returns the active round if one exists (whatever its status). Otherwise
|
"""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)
|
opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
|
||||||
|
|||||||
+21
-2
@@ -544,6 +544,14 @@ function switchPanel(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let roundCloseAt = null;
|
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 roundTimerInterval = null;
|
||||||
let roundPollTimeout = null;
|
let roundPollTimeout = null;
|
||||||
|
|
||||||
@@ -649,7 +657,7 @@ let timerHitZero = false;
|
|||||||
function updateRoundTimer() {
|
function updateRoundTimer() {
|
||||||
const el = document.getElementById('round-timer');
|
const el = document.getElementById('round-timer');
|
||||||
if (!roundCloseAt) { el.textContent = '--:--'; timerHitZero = false; return; }
|
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 totalSec = Math.max(0, rawSec);
|
||||||
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
||||||
const ss = String(totalSec % 60).padStart(2, '0');
|
const ss = String(totalSec % 60).padStart(2, '0');
|
||||||
@@ -691,8 +699,11 @@ function showNormalState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRound() {
|
async function refreshRound() {
|
||||||
|
const seq = ++roundRequestSeq;
|
||||||
try {
|
try {
|
||||||
const data = await call('GET', '/rounds/current');
|
const data = await call('GET', '/rounds/current');
|
||||||
|
if (seq < roundAppliedSeq) return; // a newer refreshRound() call already applied its result
|
||||||
|
roundAppliedSeq = seq;
|
||||||
noteFetchOutcome(true);
|
noteFetchOutcome(true);
|
||||||
updateChainStatusBar(data);
|
updateChainStatusBar(data);
|
||||||
document.getElementById('round-title').textContent = data.round_id
|
document.getElementById('round-title').textContent = data.round_id
|
||||||
@@ -708,6 +719,7 @@ async function refreshRound() {
|
|||||||
jackpotEl.classList.add('jackpot-bump');
|
jackpotEl.classList.add('jackpot-bump');
|
||||||
}
|
}
|
||||||
lastJackpotValue = jackpotValue;
|
lastJackpotValue = jackpotValue;
|
||||||
|
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
|
||||||
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
|
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
|
||||||
updateRoundTimer();
|
updateRoundTimer();
|
||||||
|
|
||||||
@@ -735,6 +747,13 @@ async function refreshRound() {
|
|||||||
} else if (data.round_id && data.round_id !== activeResultRoundId) {
|
} else if (data.round_id && data.round_id !== activeResultRoundId) {
|
||||||
// a genuinely new round is open — clear any previous result and go back to normal
|
// a genuinely new round is open — clear any previous result and go back to normal
|
||||||
activeResultRoundId = null;
|
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();
|
showNormalState();
|
||||||
} else if (!data.round_id && activeResultRoundId == null) {
|
} else if (!data.round_id && activeResultRoundId == null) {
|
||||||
// nothing has ever been revealed and there's no active round — plain empty state
|
// 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
|
// 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
|
// 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.
|
// 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);
|
scheduleNextRoundPoll(isDrawing || pastDeadline);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
noteFetchOutcome(false);
|
noteFetchOutcome(false);
|
||||||
|
|||||||
+1
-1
@@ -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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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). |
|
| **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
@@ -35,14 +35,19 @@ Dopo l'accesso vedi, in ordine:
|
|||||||
|
|
||||||
### Estrazione del vincitore
|
### Estrazione del vincitore
|
||||||
|
|
||||||
Quando il round chiude, **le bet non sono più accettate** e la card del
|
Appena il timer arriva a zero, **nessun nuovo giocatore può più entrare nel
|
||||||
round mostra un'animazione ("Estrazione del vincitore in corso…") al posto
|
round** — è un "semaforo giallo": il conteggio raggiunto lo zero blocca da
|
||||||
del timer — la stessa cosa compare nella dashboard di ogni giocatore, non
|
subito le nuove giocate, ma il round non chiude immediatamente. Se qualcuno
|
||||||
solo la tua. L'animazione resta visibile per almeno un tempo minimo
|
aveva già piazzato una bet negli ultimi istanti (transazione trasmessa ma
|
||||||
configurabile dall'admin (default 20s), ma può durare più a lungo: il
|
non ancora confermata), il round aspetta che anche quella si confermi prima
|
||||||
vincitore viene scelto usando l'hash del primo blocco confermato dopo la
|
di procedere, così nessuna giocata già fatta viene persa al confine del
|
||||||
chiusura, quindi il tempo reale dipende dalla rete (mediamente ~2 minuti,
|
round. Solo a quel punto la card mostra un'animazione ("Estrazione del
|
||||||
il block time di PLM).
|
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
|
Appena il vincitore è determinato, l'animazione lascia spazio a un
|
||||||
messaggio:
|
messaggio:
|
||||||
|
|||||||
+30
-1
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
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.bets.service import BetError, place_bet
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.base import Base
|
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
|
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:
|
async with session_factory() as session:
|
||||||
participants = (await session.scalars(select(RoundParticipant))).all()
|
participants = (await session.scalars(select(RoundParticipant))).all()
|
||||||
assert len(participants) == 1
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user