diff --git a/BUGS.md b/BUGS.md index e5a0f91..0eb1b1d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -40,19 +40,6 @@ remains the last prerequisite for running unattended. ## Medium — correctness and robustness -### B-65 — `/rounds/current` counts unconfirmed participants; the draw and payout do not - -`app/api/routes/rounds.py:131-143` vs `app/rounds/scheduler.py:126`. - -`participant_count` and `jackpot_sats` are computed over *all* `round_participants` -rows, while the draw and the payout only use `status == "confirmed"`. So the -advertised jackpot can exceed what is actually paid out, and a participant whose -bet is later abandoned appears in the count and then vanishes from it. - -Fix: either count only `confirmed` (and accept that a fresh bet takes a block to -show up), or expose the two figures separately (confirmed vs in-flight) so the -number on screen and the number that gets paid agree by construction. - ### B-66 — nothing stops rounds from opening with no `fee_address` configured `app/rounds/config.py:12-17`, `app/rounds/scheduler.py:330-335`. diff --git a/CLAUDE.md b/CLAUDE.md index e6138ef..91f8388 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW | Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` | | Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.py` | -`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11). +`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11). It counts **confirmed participants only** (B-65), matching what the draw picks from and what the payout can spend, with `pending_participant_count`/`pending_jackpot_sats`/`has_pending_bets` reporting the in-flight bets alongside — inclusive figures, not deltas, exactly like `pending_balance_sats` (see "Balance display"). `/`'s round card shows the confirmed numbers big and the difference as an amber "+N in attesa" suffix, so a player who just bet sees their own bet immediately without the advertised jackpot ever exceeding what will be paid. **Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome. diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index f7b98b8..60cb0fb 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -91,9 +91,21 @@ class CurrentRoundResponse(BaseModel): status: str | None = None opened_at: str | None = None closes_at: str | None = None + # B-65: confirmed participants only — the ones the draw actually picks from and + # whose sats are actually in the pool. The pending_* pair below is the same + # confirmed/in-flight split the balance already exposes (see + # wallet/balance.py's balance_sats vs pending_balance_sats), and for the same + # reason: the authoritative number must be the one that will be paid, while the + # player who just bet still needs to see their own bet somewhere. participant_count: int = 0 bet_amount_sats: int jackpot_sats: int = 0 + # Inclusive of bets still building/broadcast, exactly like pending_balance_sats + # is inclusive of unconfirmed change — not deltas. Equal to the confirmed + # figures above when nothing is in flight, which is what has_pending_bets says. + pending_participant_count: int = 0 + pending_jackpot_sats: int = 0 + has_pending_bets: bool = False draw_animation_seconds: int winner_user_id: int | None = None winner_amount_sats: int | None = None @@ -128,19 +140,32 @@ async def current_round( lottery_paused=config.paused, ) - participant_count = await session.scalar( - select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id) - ) or 0 # The pool is the sum of what the participants' bets actually paid into the pool # address — each one is already net of that bet's network fee. Deriving it from # participant_count * the *current* bet_amount_sats instead overstated it, and # silently changed the advertised jackpot of a round in progress whenever an # operator edited the bet amount (B-11). - pool_amount_sats = await session.scalar( - select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where( - RoundParticipant.round_id == round_.id + # + # Split confirmed from in-flight (B-65): the draw only picks from confirmed + # participants and the payout only spends their sats, so counting every row + # advertised a jackpot larger than the one that would be paid, and made a + # participant appear and then vanish again if their bet was later abandoned. + counts = ( + await session.execute( + select( + func.count(), + func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0), + func.count().filter(RoundParticipant.status == "confirmed"), + func.coalesce( + func.sum(RoundParticipant.bet_amount_sats).filter( + RoundParticipant.status == "confirmed" + ), + 0, + ), + ).where(RoundParticipant.round_id == round_.id) ) - ) or 0 + ).one() + all_count, all_pool_sats, participant_count, pool_amount_sats = counts opened_at = round_.opened_at.replace(tzinfo=timezone.utc) # B-61: from the round's own duration — the countdown clients are watching must # not jump because an operator edited the config mid-round. @@ -178,6 +203,9 @@ async def current_round( participant_count=participant_count, bet_amount_sats=config.bet_amount_sats, jackpot_sats=jackpot_sats, + pending_participant_count=all_count, + pending_jackpot_sats=winner_share(all_pool_sats), + has_pending_bets=all_count > participant_count, draw_animation_seconds=config.draw_animation_seconds, winner_user_id=round_.winner_user_id, winner_amount_sats=round_.winner_amount_sats, diff --git a/app/static/app.js b/app/static/app.js index ea8787a..a92a47d 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -368,6 +368,49 @@ async function checkLastRoundResult() { let lastJackpotValue = null; +// The round's own confirmed/in-flight split (B-65). The big numbers are the +// confirmed ones — the players the draw will pick from and the pool the payout +// will actually spend — because a jackpot advertised larger than the one paid out +// is the kind of gap nobody forgives. What has been bet but hasn't confirmed yet +// is shown next to them instead of being folded in, so the player who just bet +// still sees their own bet immediately (the same reasoning as the amber +// pending balance, see setBalanceDisplay). +// +// Kept out of the [data-i18n] mechanism on purpose: these come from server data, +// so onLanguageChange() re-renders them through t() like every other dynamic bit. +let lastRoundStats = null; + +function renderRoundStats(data) { + lastRoundStats = data; + document.getElementById('round-players').textContent = data.participant_count; + + const jackpotEl = document.getElementById('round-jackpot'); + const jackpotValue = data.jackpot_sats / SATS_PER_PLM; + jackpotEl.textContent = formatPlm(data.jackpot_sats); + if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) { + jackpotEl.classList.remove('jackpot-bump'); + void jackpotEl.offsetWidth; // restart the animation + jackpotEl.classList.add('jackpot-bump'); + } + lastJackpotValue = jackpotValue; + + // pending_* are inclusive of the confirmed figures (like pending_balance_sats), + // so what's shown alongside is the difference. + const pendingPlayers = (data.pending_participant_count || 0) - data.participant_count; + const pendingJackpotSats = (data.pending_jackpot_sats || 0) - data.jackpot_sats; + const show = !!data.has_pending_bets && pendingPlayers > 0; + + const playersPendingEl = document.getElementById('round-players-pending'); + playersPendingEl.textContent = show ? t('round.playersPending', { n: pendingPlayers }) : ''; + playersPendingEl.classList.toggle('hidden', !show); + + const jackpotPendingEl = document.getElementById('round-jackpot-pending'); + jackpotPendingEl.textContent = show + ? t('round.jackpotPending', { amount: formatPlm(pendingJackpotSats) }) + : ''; + jackpotPendingEl.classList.toggle('hidden', !show); +} + let timerHitZero = false; function updateRoundTimer() { @@ -451,16 +494,7 @@ async function refreshRound() { : t('round.none'); betAmountSats = data.bet_amount_sats; renderBetButton(); - document.getElementById('round-players').textContent = data.participant_count; - const jackpotEl = document.getElementById('round-jackpot'); - const jackpotValue = data.jackpot_sats / SATS_PER_PLM; - jackpotEl.textContent = formatPlm(data.jackpot_sats); - if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) { - jackpotEl.classList.remove('jackpot-bump'); - void jackpotEl.offsetWidth; // restart the animation - jackpotEl.classList.add('jackpot-bump'); - } - lastJackpotValue = jackpotValue; + renderRoundStats(data); if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date(); roundCloseAt = data.closes_at ? new Date(data.closes_at) : null; updateRoundTimer(); @@ -858,6 +892,10 @@ function connectRoundEvents() { function onLanguageChange() { renderBetButton(); renderChainStatusBar(); // repaints from remembered state, without waiting for the next poll + // Same reason, for the round's "+N in attesa" suffixes (B-65): repaint from what + // was last received rather than leaving them in the old language until the + // refreshRound() below happens to come back. + if (lastRoundStats) renderRoundStats(lastRoundStats); if (token) { refreshRound(); refreshMe(); diff --git a/app/static/i18n.js b/app/static/i18n.js index b21cbfb..2be3fdc 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -63,6 +63,8 @@ const TRANSLATIONS = { 'round.players': 'Players', 'round.jackpot': 'Jackpot', + 'round.playersPending': '+{n} pending', + 'round.jackpotPending': '+{amount} pending', 'round.status.open': 'open', 'round.status.closing': 'closing', 'round.status.drawing': 'drawing in progress', @@ -228,6 +230,8 @@ const TRANSLATIONS = { 'round.players': 'Giocatori', 'round.jackpot': 'Jackpot', + 'round.playersPending': '+{n} in attesa', + 'round.jackpotPending': '+{amount} in attesa', 'round.status.open': 'aperto', 'round.status.closing': 'in chiusura', 'round.status.drawing': 'estrazione in corso', @@ -390,6 +394,8 @@ const TRANSLATIONS = { 'round.players': 'Jugadores', 'round.jackpot': 'Bote', + 'round.playersPending': '+{n} pendientes', + 'round.jackpotPending': '+{amount} pendientes', 'round.status.open': 'abierta', 'round.status.closing': 'cerrando', 'round.status.drawing': 'sorteo en curso', @@ -552,6 +558,8 @@ const TRANSLATIONS = { 'round.players': 'Joueurs', 'round.jackpot': 'Jackpot', + 'round.playersPending': '+{n} en attente', + 'round.jackpotPending': '+{amount} en attente', 'round.status.open': 'ouvert', 'round.status.closing': 'en fermeture', 'round.status.drawing': 'tirage en cours', @@ -714,6 +722,8 @@ const TRANSLATIONS = { 'round.players': 'Spieler', 'round.jackpot': 'Jackpot', + 'round.playersPending': '+{n} ausstehend', + 'round.jackpotPending': '+{amount} ausstehend', 'round.status.open': 'offen', 'round.status.closing': 'wird geschlossen', 'round.status.drawing': 'Ziehung läuft', @@ -876,6 +886,8 @@ const TRANSLATIONS = { 'round.players': 'Игроки', 'round.jackpot': 'Джекпот', + 'round.playersPending': '+{n} в ожидании', + 'round.jackpotPending': '+{amount} в ожидании', 'round.status.open': 'открыт', 'round.status.closing': 'закрывается', 'round.status.drawing': 'идёт розыгрыш', @@ -1038,6 +1050,8 @@ const TRANSLATIONS = { 'round.players': '参与人数', 'round.jackpot': '奖池', + 'round.playersPending': '+{n} 待确认', + 'round.jackpotPending': '+{amount} 待确认', 'round.status.open': '进行中', 'round.status.closing': '即将结束', 'round.status.drawing': '正在开奖', diff --git a/app/static/index.html b/app/static/index.html index 666fe64..e50f415 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -150,11 +150,15 @@
Giocatori
- + +
Jackpot
PLM +
diff --git a/app/static/style.css b/app/static/style.css index 08937c5..403be99 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -215,6 +215,12 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground); .balance-confirmed { color: var(--color-success); } .balance-pending { color: var(--color-primary); } +/* The in-flight part of the round's own figures (B-65): the players/jackpot next + to it are the confirmed ones the draw and the payout will actually use, and this + is what has been bet but hasn't confirmed yet. Same amber as .balance-pending, + for the same "not settled" meaning. */ +.pending-suffix { color: var(--color-primary); font-size: 0.8rem; font-weight: 600; } + .icon { width: 16px; height: 16px; flex-shrink: 0; } .dash-panel { display: none; } diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py index 6deedad..1fd07a2 100644 --- a/tests/unit/test_rounds_route.py +++ b/tests/unit/test_rounds_route.py @@ -134,6 +134,64 @@ async def test_jackpot_comes_from_the_participants_actual_bets(client): assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100 +async def test_advertised_jackpot_covers_only_the_bets_that_will_be_paid(client): # B-65 + """The draw picks from confirmed participants and the payout spends only their + sats, so counting every participant row advertised a jackpot bigger than the one + that would actually be paid — and let a player appear in the count and then + vanish again if their bet was abandoned. The confirmed figures are the headline + ones; what's in flight is reported alongside, never folded in.""" + from app.db.models import Round, RoundConfig, RoundParticipant + + ac, session_factory = client + + async with session_factory() as session: + session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000)) + session.add(Round(id=60, status="open")) + await session.flush() + session.add( + RoundParticipant(round_id=60, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed") + ) + # One mid-broadcast and one written but not yet broadcast: both in flight, + # neither drawn from nor spent by the payout as things stand. + session.add( + RoundParticipant(round_id=60, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="broadcast") + ) + session.add( + RoundParticipant(round_id=60, user_id=3, bet_amount_sats=999_800_000, bet_txid="c", status="building") + ) + await session.commit() + + body = (await ac.get("/rounds/current")).json() + + assert body["participant_count"] == 1 + assert body["jackpot_sats"] == 999_800_000 * 70 // 100 + # Inclusive, like pending_balance_sats — not a delta. + assert body["pending_participant_count"] == 3 + assert body["pending_jackpot_sats"] == (999_800_000 * 3) * 70 // 100 + assert body["has_pending_bets"] is True + + +async def test_no_pending_bets_reported_once_every_bet_has_confirmed(client): # B-65 + from app.db.models import Round, RoundConfig, RoundParticipant + + ac, session_factory = client + + async with session_factory() as session: + session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000)) + session.add(Round(id=61, status="open")) + await session.flush() + session.add( + RoundParticipant(round_id=61, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed") + ) + await session.commit() + + body = (await ac.get("/rounds/current")).json() + + assert body["has_pending_bets"] is False + assert body["pending_participant_count"] == body["participant_count"] == 1 + assert body["pending_jackpot_sats"] == body["jackpot_sats"] + + async def test_draw_waiting_since_is_exposed_only_while_drawing(client): """B-36: the "drawing" wait on a future block has no timeout, so the frontend needs draw_waiting_since to show "still waiting" instead of implying a bounded