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:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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': '正在开奖',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user