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
+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; }