Show pending-inclusive balance and per-player round outcome reliably

Balance display: place_bet/request_withdrawal spend whole UTXOs and mark
them spent at broadcast time, well before confirmation, so the confirmed-only
balance could drop by far more than the amount actually moving. Add
compute_pending_balance() (app/wallet/balance.py) to fold the unconfirmed
change from in-flight bet/withdrawal PendingTransactions back in; GET
/users/me now returns pending_balance_sats + has_pending, and the frontend
shows it colored green (settled) or amber (still pending) instead of the
confirmed-only figure.

Round outcome display: the win/lose reveal and the "pagamento al vincitore
in corso" status were fighting over the same UI slot, and the reveal broke
across a page refresh. Now:
- The round-status box (generic phase progress) and the personal win/lose
  box are independent and can both be visible at once.
- The win/lose box only renders for users who actually played in that round
  (new user_played field on GET /rounds/current, via a new optional-auth
  dependency so the endpoint stays usable logged-out).
- The reveal delay is anchored to the round's server-provided closes_at
  instead of a client-side "first seen" timestamp, so repeated reloads can't
  reset it, and the revealed result is persisted in localStorage so it
  survives a refresh even after the round has fully closed.
- GET /users/me/last-round-result is a durable DB-backed backstop for
  players who miss the live window entirely (backgrounded tab, offline).

Also hardens the frontend polling loop: call() now times out instead of
hanging forever, and a session-epoch counter stops an in-flight request from
a previous login from resurrecting a duplicate poll loop after logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 10:09:12 +02:00
co-authored by Claude Sonnet 5
parent f822911128
commit 6a857f0e07
8 changed files with 459 additions and 80 deletions
+24 -2
View File
@@ -5,7 +5,8 @@ from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import RoundParticipant from app.auth.dependencies import get_optional_user
from app.db.models import RoundParticipant, User
from app.db.session import get_session from app.db.session import get_session
from app.rounds.config import get_round_config from app.rounds.config import get_round_config
from app.rounds.service import get_active_round from app.rounds.service import get_active_round
@@ -29,10 +30,15 @@ class CurrentRoundResponse(BaseModel):
draw_block_hash: str | None = None draw_block_hash: str | None = None
chain_tip_height: int | None = None chain_tip_height: int | None = None
lottery_paused: bool = False lottery_paused: bool = False
user_played: bool = False
@router.get("/current", response_model=CurrentRoundResponse) @router.get("/current", response_model=CurrentRoundResponse)
async def current_round(request: Request, session: AsyncSession = Depends(get_session)) -> CurrentRoundResponse: async def current_round(
request: Request,
session: AsyncSession = Depends(get_session),
user: User | None = Depends(get_optional_user),
) -> CurrentRoundResponse:
config = await get_round_config(session) config = await get_round_config(session)
round_ = await get_active_round(session) round_ = await get_active_round(session)
listener = request.app.state.electrum_listener listener = request.app.state.electrum_listener
@@ -52,6 +58,21 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se
) or 0 ) or 0
opened_at = round_.opened_at.replace(tzinfo=timezone.utc) opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds) closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
# Lets the frontend show the personalized win/lose reveal only to players in
# this round — everyone else (not logged in, or logged in but didn't bet)
# just sees the generic phase progress instead of a "non hai vinto" that
# wouldn't mean anything to them.
user_played = False
if user is not None:
user_played = (
await session.scalar(
select(RoundParticipant).where(
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
)
)
) is not None
await session.commit() await session.commit()
# Shown to players as "jackpot": the winner's 70% share of the pool (same # Shown to players as "jackpot": the winner's 70% share of the pool (same
@@ -76,4 +97,5 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se
draw_block_hash=round_.draw_block_hash, draw_block_hash=round_.draw_block_hash,
chain_tip_height=chain_tip_height, chain_tip_height=chain_tip_height,
lottery_paused=config.paused, lottery_paused=config.paused,
user_played=user_played,
) )
+10 -1
View File
@@ -7,6 +7,7 @@ from app.auth.dependencies import get_current_user
from app.auth.security import hash_password, verify_password from app.auth.security import hash_password, verify_password
from app.db.models import Round, RoundParticipant, User from app.db.models import Round, RoundParticipant, User
from app.db.session import get_session from app.db.session import get_session
from app.wallet.balance import compute_pending_balance
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -18,16 +19,24 @@ class MeResponse(BaseModel):
username: str username: str
address: str address: str
balance_sats: int balance_sats: int
pending_balance_sats: int
has_pending: bool
created_at: str created_at: str
@router.get("/me", response_model=MeResponse) @router.get("/me", response_model=MeResponse)
async def me(user: User = Depends(get_current_user)) -> MeResponse: async def me(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MeResponse:
pending_balance_sats, has_pending = await compute_pending_balance(session, user)
return MeResponse( return MeResponse(
id=user.id, id=user.id,
username=user.username, username=user.username,
address=user.address, address=user.address,
balance_sats=user.cached_balance_sats, balance_sats=user.cached_balance_sats,
pending_balance_sats=pending_balance_sats,
has_pending=has_pending,
created_at=user.created_at.isoformat(), created_at=user.created_at.isoformat(),
) )
+18 -1
View File
@@ -1,4 +1,4 @@
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,3 +23,20 @@ async def get_current_user(
if user is None: if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
return user return user
async def get_optional_user(
request: Request,
session: AsyncSession = Depends(get_session),
) -> User | None:
"""Like get_current_user, but for endpoints reachable both logged-out and
logged-in (e.g. /rounds/current) that need to personalize their response
*if* the caller happens to be authenticated, without requiring it."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
try:
user_id = decode_access_token(auth_header.removeprefix("Bearer "))
except Exception:
return None
return await session.scalar(select(User).where(User.id == user_id))
+143 -69
View File
@@ -325,7 +325,7 @@ const ROUND_STATUS_LABELS = {
open: 'aperto', open: 'aperto',
closing: 'in chiusura', closing: 'in chiusura',
drawing: 'estrazione in corso', drawing: 'estrazione in corso',
paying_out: 'pagamento in corso', paying_out: 'pagamento al vincitore in corso',
}; };
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out']; const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
@@ -342,9 +342,9 @@ function drawingLabelFor(data) {
} }
// paying_out // paying_out
if (data.draw_block_height != null) { if (data.draw_block_height != null) {
return 'Vincitore estratto dal blocco #' + data.draw_block_height + ' — pagamento in corso…'; return 'Vincitore estratto dal blocco #' + data.draw_block_height + ' — pagamento al vincitore in corso…';
} }
return 'Vincitore estratto — pagamento in corso…'; return 'Vincitore estratto — pagamento al vincitore in corso…';
} }
// One label per real round status, not just the coarse open/drawing/waiting // One label per real round status, not just the coarse open/drawing/waiting
@@ -355,7 +355,7 @@ const CHAIN_STATUS_LABELS = {
open: 'Round aperto', open: 'Round aperto',
closing: 'Round chiuso — attesa conferma puntate', closing: 'Round chiuso — attesa conferma puntate',
drawing: 'Estrazione in corso', drawing: 'Estrazione in corso',
paying_out: 'Pagamento in corso', paying_out: 'Pagamento al vincitore in corso',
}; };
function updateChainStatusBar(data) { function updateChainStatusBar(data) {
@@ -438,12 +438,46 @@ document.addEventListener('visibilitychange', () => {
} }
}); });
const LAST_SEEN_RESULT_KEY = 'plm_last_seen_result_round_id'; // The win/lose box's content lives in localStorage, not just in-memory state —
// a page reload (or a completely fresh tab) must be able to redraw it exactly
// as it was, without waiting for a new poll or re-running the reveal
// animation. This is the single source of truth for "what result box (if any)
// is currently shown"; refreshRound() and checkLastRoundResult() below both
// read/write it instead of keeping their own separate notion of "revealed".
const PERSISTED_RESULT_KEY = 'plm_persisted_result';
function markResultSeen(roundId) { function getPersistedResult() {
localStorage.setItem(LAST_SEEN_RESULT_KEY, String(roundId)); try {
return JSON.parse(localStorage.getItem(PERSISTED_RESULT_KEY));
} catch (e) {
return null;
}
} }
function persistResult(roundId, won, amountSats) {
localStorage.setItem(PERSISTED_RESULT_KEY, JSON.stringify({ round_id: roundId, won, amount_sats: amountSats }));
}
function clearPersistedResult() {
localStorage.removeItem(PERSISTED_RESULT_KEY);
}
function renderPersistedResult(result) {
setRoundInfoVisible(false);
setResultBoxVisible(
true,
result.won ? '🎉 Hai vinto! +' + (result.amount_sats / SATS_PER_PLM) + ' PLM' : 'Non hai vinto questa volta.',
result.won ? 'win' : 'lose'
);
}
// The most recent round_id refreshRound() actually saw from the server (null
// meaning "confirmed no active round"; undefined meaning "haven't polled yet").
// Lets checkLastRoundResult() below avoid clobbering a round that's already
// known to be open/in-progress by the time its own (slower, DB-backed) request
// resolves.
let currentRoundIdSeen;
// Backstop for the live reveal in refreshRound(): that one only works if a poll // Backstop for the live reveal in refreshRound(): that one only works if a poll
// happens to land while the round is still "paying_out" (winner_user_id is // happens to land while the round is still "paying_out" (winner_user_id is
// dropped from /rounds/current the instant the round flips to "closed" — see // dropped from /rounds/current the instant the round flips to "closed" — see
@@ -461,23 +495,19 @@ async function checkLastRoundResult() {
return; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path return; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path
} }
if (data.round_id == null) return; if (data.round_id == null) return;
if (String(data.round_id) === localStorage.getItem(LAST_SEEN_RESULT_KEY)) return; // already surfaced (live or backstop) const persisted = getPersistedResult();
markResultSeen(data.round_id); if (persisted && persisted.round_id === data.round_id) return; // already showing/known
if (currentRoundIdSeen != null && currentRoundIdSeen !== data.round_id) return; // a newer round is already in progress on screen
persistResult(data.round_id, data.won, data.amount_sats);
renderPersistedResult({ won: data.won, amount_sats: data.amount_sats });
if (data.won) { if (data.won) {
const won = data.amount_sats / SATS_PER_PLM; const won = data.amount_sats / SATS_PER_PLM;
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success'); toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
refreshMe(); refreshMe();
} else {
toast('Round #' + data.round_id + ' concluso: non hai vinto questa volta.', 'info');
} }
} }
// Per round_id: when we first saw it enter a drawing status (client clock), and
// whether the win/lose result has already been shown for it. Local-only state,
// not persisted — a page reload just re-derives it from the next poll.
const drawStartedAt = {};
const revealedRounds = new Set();
let activeResultRoundId = null; // round_id whose win/lose result is on screen, if any
let lastJackpotValue = null; let lastJackpotValue = null;
let timerHitZero = false; let timerHitZero = false;
@@ -502,29 +532,35 @@ function updateRoundTimer() {
} }
} }
function showDrawingState(label) { // The round's normal info (title/timer/players/jackpot) vs. the drawing-phase
document.getElementById('round-normal-row').classList.add('hidden'); // spinner box vs. the personalized win/lose box are three independently
document.getElementById('round-stats-row').classList.add('hidden'); // toggled pieces, not three mutually-exclusive "screens" — during closing/
document.getElementById('draw-state').classList.add('active'); // drawing/paying_out, EVERY viewer sees the drawing box (generic phase
document.getElementById('draw-result').classList.add('hidden'); // progress), and a player who bet in that round ALSO sees the win/lose box at
if (label) document.getElementById('draw-label').textContent = label; // the same time once revealed, instead of the two fighting over one slot.
function setRoundInfoVisible(show) {
document.getElementById('round-normal-row').classList.toggle('hidden', !show);
document.getElementById('round-stats-row').classList.toggle('hidden', !show);
} }
function showResultState(html, cls) { function setDrawingBoxVisible(show, label) {
document.getElementById('round-normal-row').classList.add('hidden'); document.getElementById('draw-state').classList.toggle('active', show);
document.getElementById('round-stats-row').classList.add('hidden'); if (show && label) document.getElementById('draw-label').textContent = label;
document.getElementById('draw-state').classList.remove('active'); }
function setResultBoxVisible(show, html, cls) {
const el = document.getElementById('draw-result'); const el = document.getElementById('draw-result');
if (show) {
el.className = 'draw-result ' + cls; el.className = 'draw-result ' + cls;
el.innerHTML = html; el.innerHTML = html;
el.classList.remove('hidden'); }
el.classList.toggle('hidden', !show);
} }
function showNormalState() { function showNormalState() {
document.getElementById('round-normal-row').classList.remove('hidden'); setRoundInfoVisible(true);
document.getElementById('round-stats-row').classList.remove('hidden'); setDrawingBoxVisible(false);
document.getElementById('draw-state').classList.remove('active'); setResultBoxVisible(false);
document.getElementById('draw-result').classList.add('hidden');
} }
async function refreshRound() { async function refreshRound() {
@@ -554,47 +590,64 @@ async function refreshRound() {
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null; roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
updateRoundTimer(); updateRoundTimer();
currentRoundIdSeen = data.round_id || null;
const isDrawing = data.round_id && DRAWING_STATUSES.includes(data.status); const isDrawing = data.round_id && DRAWING_STATUSES.includes(data.status);
document.getElementById('round-card').classList.toggle('drawing-glow', !!isDrawing); document.getElementById('round-card').classList.toggle('drawing-glow', !!isDrawing);
const persisted = getPersistedResult();
if (isDrawing) { if (isDrawing) {
if (!(data.round_id in drawStartedAt)) drawStartedAt[data.round_id] = Date.now(); setRoundInfoVisible(false);
const elapsedMs = Date.now() - drawStartedAt[data.round_id]; // The drawing-phase box (spinner + phase label) is generic status info —
const minMs = data.draw_animation_seconds * 1000; // every viewer sees it for the whole closing/drawing/paying_out phase,
// regardless of whether they played in this round.
setDrawingBoxVisible(true, drawingLabelFor(data));
if (data.winner_user_id != null && elapsedMs >= minMs && !revealedRounds.has(data.round_id)) { // The cosmetic reveal delay is anchored to the server's closes_at, not to
revealedRounds.add(data.round_id); // any client-side "when did I first see this" timestamp — a page reload
activeResultRoundId = data.round_id; // (or repeated reloads) can never reset it, since it's derived purely
markResultSeen(data.round_id); // from server-provided values that don't change for this round.
if (myUserId != null && data.winner_user_id === myUserId) { const elapsedMs = serverNow() - new Date(data.closes_at);
const won = (data.winner_amount_sats / SATS_PER_PLM); const minMs = data.draw_animation_seconds * 1000;
showResultState('🎉 Hai vinto! +' + won + ' PLM', 'win'); const alreadyKnown = persisted && persisted.round_id === data.round_id;
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success'); // myUserId may not be loaded yet on the very first tick after a reload
// (refreshMe() and refreshRound() run concurrently) — fall back to the
// persisted result rather than risk showing nothing or the wrong side.
const canReveal =
data.user_played && data.winner_user_id != null && (alreadyKnown || elapsedMs >= minMs) && myUserId != null;
if (canReveal) {
const won = data.winner_user_id === myUserId;
if (!alreadyKnown) {
persistResult(data.round_id, won, data.winner_amount_sats);
if (won) {
const wonAmount = (data.winner_amount_sats / SATS_PER_PLM);
toast('Hai vinto il round #' + data.round_id + '! +' + wonAmount + ' PLM', 'success');
refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount
}
}
renderPersistedResult({ won, amount_sats: data.winner_amount_sats });
} else if (alreadyKnown) {
renderPersistedResult(persisted);
} else { } else {
showResultState('Non hai vinto questa volta.', 'lose'); setResultBoxVisible(false);
} }
} else if (!revealedRounds.has(data.round_id)) { } else {
showDrawingState(drawingLabelFor(data)); setDrawingBoxVisible(false);
} if (data.round_id && (!persisted || data.round_id !== persisted.round_id)) {
} 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; clearPersistedResult();
// 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 && !persisted) {
// 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
showNormalState(); showNormalState();
} else if (persisted) {
// no active round right now (cooldown, or a page reload after the round
// fully closed) — keep the persisted result on screen regardless, until
// a genuinely new round replaces it above.
renderPersistedResult(persisted);
}
} }
// else: no active round right now, but we just revealed a result for the last
// one — keep it on screen through the cooldown gap instead of flashing back to
// "Nessun round attivo".
scheduleNextRoundPoll(isDrawing); scheduleNextRoundPoll(isDrawing);
} catch (e) { } catch (e) {
@@ -609,7 +662,7 @@ function scheduleNextRoundPoll(fast) {
roundPollTimeout = setTimeout(refreshRound, fast ? 3000 : 15000); roundPollTimeout = setTimeout(refreshRound, fast ? 3000 : 15000);
} }
function showDashboard() { async function showDashboard() {
sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login
stopChainOnlyPolling(); stopChainOnlyPolling();
document.getElementById('landing-hero').classList.add('hidden'); document.getElementById('landing-hero').classList.add('hidden');
@@ -619,7 +672,16 @@ function showDashboard() {
document.getElementById('dash-username').textContent = username; document.getElementById('dash-username').textContent = username;
document.getElementById('dash-address').textContent = address; document.getElementById('dash-address').textContent = address;
document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address); document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address);
refreshMe(); // Render instantly from localStorage, before the network round-trip below —
// otherwise a reload right after a win/lose flashes an empty round card for
// a moment. refreshRound()'s own response reconciles this shortly after
// (e.g. hides it again if a new round has since opened).
const persisted = getPersistedResult();
if (persisted) renderPersistedResult(persisted);
// Awaited so myUserId is populated before refreshRound() decides whether
// data.winner_user_id === myUserId — otherwise that comparison could race
// against an unset myUserId right after a reload.
await refreshMe();
refreshRound(); refreshRound();
checkLastRoundResult(); checkLastRoundResult();
clearInterval(lastResultInterval); clearInterval(lastResultInterval);
@@ -676,9 +738,7 @@ function resetToLoggedOutUI() {
sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving
token = username = address = null; token = username = address = null;
myUserId = null; myUserId = null;
activeResultRoundId = null; currentRoundIdSeen = undefined;
revealedRounds.clear();
for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key];
clearInterval(roundTimerInterval); clearInterval(roundTimerInterval);
clearTimeout(roundPollTimeout); clearTimeout(roundPollTimeout);
clearInterval(lastResultInterval); clearInterval(lastResultInterval);
@@ -734,7 +794,19 @@ async function copyAddress() {
} }
let myUserId = null; let myUserId = null;
let myBalanceSats = 0; let myBalanceSats = 0; // confirmed, spendable balance — what withdrawals/bets can actually draw from
// Shows the pending-inclusive balance (confirmed + own change still unconfirmed
// in a broadcast bet/withdrawal — see compute_pending_balance in
// app/wallet/balance.py) so the number doesn't drop by more than the amount
// actually spent while a tx is in flight. Green once settled, amber while
// has_pending is true so it's clear the figure isn't final yet.
function setBalanceDisplay(elementId, pendingBalanceSats, hasPending) {
const el = document.getElementById(elementId);
el.textContent = pendingBalanceSats / SATS_PER_PLM;
el.classList.toggle('balance-pending', hasPending);
el.classList.toggle('balance-confirmed', !hasPending);
}
async function refreshMe() { async function refreshMe() {
const btn = document.getElementById('refresh-btn'); const btn = document.getElementById('refresh-btn');
@@ -743,11 +815,13 @@ async function refreshMe() {
const data = await call('GET', '/users/me'); const data = await call('GET', '/users/me');
myUserId = data.id; myUserId = data.id;
myBalanceSats = data.balance_sats; myBalanceSats = data.balance_sats;
document.getElementById('dash-balance').textContent = data.balance_sats / SATS_PER_PLM; setBalanceDisplay('dash-balance', data.pending_balance_sats, data.has_pending);
document.getElementById('navbar-balance').textContent = (data.balance_sats / SATS_PER_PLM) + ' PLM'; document.getElementById('navbar-balance').textContent = (data.pending_balance_sats / SATS_PER_PLM) + ' PLM';
document.getElementById('navbar-balance').classList.toggle('balance-pending', data.has_pending);
document.getElementById('navbar-balance').classList.toggle('balance-confirmed', !data.has_pending);
document.getElementById('profile-username').textContent = data.username; document.getElementById('profile-username').textContent = data.username;
document.getElementById('profile-address').textContent = data.address; document.getElementById('profile-address').textContent = data.address;
document.getElementById('profile-balance').textContent = data.balance_sats / SATS_PER_PLM; setBalanceDisplay('profile-balance', data.pending_balance_sats, data.has_pending);
document.getElementById('profile-created-at').textContent = new Date(data.created_at).toLocaleDateString('it-IT'); document.getElementById('profile-created-at').textContent = new Date(data.created_at).toLocaleDateString('it-IT');
document.getElementById('wd-full-amount-value').textContent = data.balance_sats / SATS_PER_PLM; document.getElementById('wd-full-amount-value').textContent = data.balance_sats / SATS_PER_PLM;
if (document.getElementById('wd-full-amount').checked) { if (document.getElementById('wd-full-amount').checked) {
+5
View File
@@ -208,6 +208,11 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
.balance-value { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; } .balance-value { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; }
.balance-unit { color: var(--color-muted-foreground); font-size: 1rem; font-weight: 500; } .balance-unit { color: var(--color-muted-foreground); font-size: 1rem; font-weight: 500; }
/* Green once everything is confirmed; amber while a bet/withdrawal's change is
still unconfirmed — the displayed number already includes that change (see
compute_pending_balance), the color just flags that it isn't settled yet. */
.balance-confirmed { color: var(--color-success); }
.balance-pending { color: var(--color-primary); }
.icon { width: 16px; height: 16px; flex-shrink: 0; } .icon { width: 16px; height: 16px; flex-shrink: 0; }
+46 -1
View File
@@ -1,7 +1,9 @@
from embit.transaction import Transaction
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User, UtxoEvent from app.db.models import PendingTransaction, User, UtxoEvent
from app.wallet.plm_network import PLM_MAINNET
async def recompute_balance(session: AsyncSession, user_id: int) -> int: async def recompute_balance(session: AsyncSession, user_id: int) -> int:
@@ -14,3 +16,46 @@ async def recompute_balance(session: AsyncSession, user_id: int) -> int:
user = await session.get(User, user_id) user = await session.get(User, user_id)
user.cached_balance_sats = balance or 0 user.cached_balance_sats = balance or 0
return user.cached_balance_sats return user.cached_balance_sats
async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[int, bool]:
"""Balance including the user's own change still in flight.
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
much larger than the amount actually moving, since select_utxos() picks
whole UTXOs — and recompute_balance() drops that entire input total from
cached_balance_sats the moment the tx is broadcast (spent_txid is set right
away, well before the tx has any confirmations). The change output that
returns the difference only becomes a UtxoEvent (and so counts toward the
confirmed balance again) once it gets its own 1st confirmation. In between,
User.cached_balance_sats alone understates the user's real balance by the
full unconfirmed change amount, which can look like a much bigger loss than
the tx actually represents.
This walks every in-flight (status="pending") bet/withdrawal PendingTransaction
of this user, decodes its current raw tx (kept up to date across RBF bumps —
see tx/broadcast.py:bump_fee), and sums whichever outputs pay back to the
user's own address. Adding that to cached_balance_sats gives the balance the
user will end up with once everything currently in flight confirms.
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
caller whether this differs from the confirmed-only balance at all.
"""
pending = (
await session.scalars(
select(PendingTransaction).where(
PendingTransaction.user_id == user.id,
PendingTransaction.kind.in_(("bet", "withdrawal")),
PendingTransaction.status == "pending",
)
)
).all()
pending_change_sats = 0
for row in pending:
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
for out in tx.vout:
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
pending_change_sats += out.value
return user.cached_balance_sats + pending_change_sats, bool(pending)
+111
View File
@@ -0,0 +1,111 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, User, UtxoEvent
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_user_address
class FakeElectrumClient:
async def broadcast(self, raw_tx_hex: str) -> str:
return "fake-network-txid"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int:
async with session_factory() as session:
address = derive_user_address(index)
user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address)
session.add(user)
await session.commit()
session.add(
UtxoEvent(
user_id=user.id,
txid=f"{index:02x}" * 32,
vout=0,
amount_sats=funded_sats,
confirmed_height=100,
)
)
await recompute_balance(session, user.id)
await session.commit()
return user.id
async def test_pending_balance_includes_unconfirmed_change(session_factory):
"""A bet spends a whole (much larger) UTXO and the change hasn't confirmed
yet, so cached_balance_sats alone understates the user's real balance by
the entire unconfirmed change amount — compute_pending_balance should add
it back."""
user_id = await _make_funded_user(session_factory, 0, 1_500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, client, user)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0 # the whole funding UTXO was spent as input
pending_balance, has_pending = await compute_pending_balance(session, user)
assert has_pending is True
# confirmed (0) + unconfirmed change should be just under the original
# funding amount (minus the bet amount and the network fee)
assert 0 < pending_balance < 1_500_000_000
async def test_pending_balance_matches_confirmed_when_nothing_in_flight(session_factory):
user_id = await _make_funded_user(session_factory, 1, 2_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
pending_balance, has_pending = await compute_pending_balance(session, user)
assert has_pending is False
assert pending_balance == 2_000_000_000
async def test_pending_balance_ignores_other_users_pending_transactions(session_factory):
user_id = await _make_funded_user(session_factory, 2, 2_000_000_000)
other_user_id = await _make_funded_user(session_factory, 3, 1_500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
other_user = await session.get(User, other_user_id)
await place_bet(session, client, other_user)
async with session_factory() as session:
pending_rows = (await session.scalars(select(PendingTransaction))).all()
assert len(pending_rows) == 1 # sanity: only the other user has anything in flight
user = await session.get(User, user_id)
pending_balance, has_pending = await compute_pending_balance(session, user)
assert has_pending is False
assert pending_balance == 2_000_000_000
+96
View File
@@ -0,0 +1,96 @@
import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from fastapi import FastAPI
from app.api.routes.rounds import router as rounds_router
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.include_router(rounds_router)
app.state.electrum_listener = ElectrumListener(lambda: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac, db_base.AsyncSessionLocal
await db_base.engine.dispose()
async def _register(ac, username):
resp = await ac.post("/auth/register", json={"username": username, "password": "hunter2hunter"})
assert resp.status_code == 201
data = resp.json()
return data["access_token"], data["user_id"] if "user_id" in data else None
async def test_user_played_true_only_for_participants(client):
ac, session_factory = client
from app.db.models import Round, RoundConfig, RoundParticipant, User
player_token, _ = await _register(ac, "player")
spectator_token, _ = await _register(ac, "spectator")
async with session_factory() as session:
from sqlalchemy import select
session.add(RoundConfig(fee_address="pool-fee-address"))
player = (await session.scalars(select(User).where(User.username == "player"))).one()
round_ = Round(status="paying_out", winner_user_id=player.id, winner_amount_sats=123)
session.add(round_)
await session.flush()
session.add(
RoundParticipant(
round_id=round_.id,
user_id=player.id,
bet_amount_sats=1_000_000_000,
bet_txid="a" * 64,
)
)
await session.commit()
resp = await ac.get("/rounds/current", headers={"Authorization": f"Bearer {player_token}"})
assert resp.status_code == 200
assert resp.json()["user_played"] is True
resp = await ac.get("/rounds/current", headers={"Authorization": f"Bearer {spectator_token}"})
assert resp.status_code == 200
assert resp.json()["user_played"] is False
resp = await ac.get("/rounds/current") # no auth at all — logged-out chain-only view
assert resp.status_code == 200
assert resp.json()["user_played"] is False