Advertise the jackpot that will actually be paid (B-65)

participant_count and jackpot_sats were computed over every round_participants
row, while the draw only picks from confirmed participants and the payout only
spends their sats. So the advertised jackpot could exceed the one paid out, and a
player whose bet was later abandoned appeared in the count and then vanished
again.

Counting only confirmed rows would have fixed the arithmetic and broken something
else: the player who just bet would see neither themselves nor their money for a
whole block. So this is the same confirmed/in-flight split the balance already
exposes (balance_sats vs pending_balance_sats): participant_count and
jackpot_sats are now the confirmed, authoritative figures, and
pending_participant_count/pending_jackpot_sats/has_pending_bets report what is in
flight — inclusive figures, not deltas, matching the balance pair's convention.

/'s round card shows the confirmed numbers big and the difference as an amber
"+N in attesa" suffix, reusing .balance-pending's colour for the same "not
settled yet" meaning. The two new spans render from server data through t(), so
they carry no data-i18n and onLanguageChange() repaints them from the last
response — the one-mechanism-per-element rule. Both strings are in all 7
languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:52:10 +02:00
co-authored by Claude Opus 5
parent c0314e2bf0
commit c4b2dc3ea2
8 changed files with 167 additions and 32 deletions
-13
View File
@@ -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`.
+1 -1
View File
@@ -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.
+35 -7
View File
@@ -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,
+48 -10
View File
@@ -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();
+14
View File
@@ -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': '正在开奖',
+5 -1
View File
@@ -150,11 +150,15 @@
<div class="row-between" style="margin-top:10px" id="round-stats-row">
<div>
<div class="hint" style="margin-bottom:2px" data-i18n="round.players">Giocatori</div>
<span class="mono" id="round-players"></span>
<!-- The two -pending spans are filled from server data by renderRoundStats()
(B-65), so they carry no data-i18n: an element belongs to one
translation mechanism or the other, never both. -->
<span class="mono" id="round-players"></span> <span class="pending-suffix hidden" id="round-players-pending"></span>
</div>
<div style="text-align:right">
<div class="hint" style="margin-bottom:2px" data-i18n="round.jackpot">Jackpot</div>
<span class="mono" id="round-jackpot"></span> <span class="balance-unit">PLM</span>
<span class="pending-suffix hidden" id="round-jackpot-pending"></span>
</div>
</div>
+6
View File
@@ -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; }
+58
View File
@@ -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