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.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.rounds.config import get_round_config
from app.rounds.service import get_active_round
@@ -29,10 +30,15 @@ class CurrentRoundResponse(BaseModel):
draw_block_hash: str | None = None
chain_tip_height: int | None = None
lottery_paused: bool = False
user_played: bool = False
@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)
round_ = await get_active_round(session)
listener = request.app.state.electrum_listener
@@ -52,6 +58,21 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se
) or 0
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
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()
# 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,
chain_tip_height=chain_tip_height,
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.db.models import Round, RoundParticipant, User
from app.db.session import get_session
from app.wallet.balance import compute_pending_balance
router = APIRouter(prefix="/users", tags=["users"])
@@ -18,16 +19,24 @@ class MeResponse(BaseModel):
username: str
address: str
balance_sats: int
pending_balance_sats: int
has_pending: bool
created_at: str
@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(
id=user.id,
username=user.username,
address=user.address,
balance_sats=user.cached_balance_sats,
pending_balance_sats=pending_balance_sats,
has_pending=has_pending,
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 sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,3 +23,20 @@ async def get_current_user(
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
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))
+149 -75
View File
@@ -325,7 +325,7 @@ const ROUND_STATUS_LABELS = {
open: 'aperto',
closing: 'in chiusura',
drawing: 'estrazione in corso',
paying_out: 'pagamento in corso',
paying_out: 'pagamento al vincitore in corso',
};
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
@@ -342,9 +342,9 @@ function drawingLabelFor(data) {
}
// paying_out
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
@@ -355,7 +355,7 @@ const CHAIN_STATUS_LABELS = {
open: 'Round aperto',
closing: 'Round chiuso — attesa conferma puntate',
drawing: 'Estrazione in corso',
paying_out: 'Pagamento in corso',
paying_out: 'Pagamento al vincitore in corso',
};
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) {
localStorage.setItem(LAST_SEEN_RESULT_KEY, String(roundId));
function getPersistedResult() {
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
// 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
@@ -461,23 +495,19 @@ async function checkLastRoundResult() {
return; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path
}
if (data.round_id == null) return;
if (String(data.round_id) === localStorage.getItem(LAST_SEEN_RESULT_KEY)) return; // already surfaced (live or backstop)
markResultSeen(data.round_id);
const persisted = getPersistedResult();
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) {
const won = data.amount_sats / SATS_PER_PLM;
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
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 timerHitZero = false;
@@ -502,29 +532,35 @@ function updateRoundTimer() {
}
}
function showDrawingState(label) {
document.getElementById('round-normal-row').classList.add('hidden');
document.getElementById('round-stats-row').classList.add('hidden');
document.getElementById('draw-state').classList.add('active');
document.getElementById('draw-result').classList.add('hidden');
if (label) document.getElementById('draw-label').textContent = label;
// The round's normal info (title/timer/players/jackpot) vs. the drawing-phase
// spinner box vs. the personalized win/lose box are three independently
// toggled pieces, not three mutually-exclusive "screens" — during closing/
// drawing/paying_out, EVERY viewer sees the drawing box (generic phase
// progress), and a player who bet in that round ALSO sees the win/lose box at
// 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) {
document.getElementById('round-normal-row').classList.add('hidden');
document.getElementById('round-stats-row').classList.add('hidden');
document.getElementById('draw-state').classList.remove('active');
function setDrawingBoxVisible(show, label) {
document.getElementById('draw-state').classList.toggle('active', show);
if (show && label) document.getElementById('draw-label').textContent = label;
}
function setResultBoxVisible(show, html, cls) {
const el = document.getElementById('draw-result');
el.className = 'draw-result ' + cls;
el.innerHTML = html;
el.classList.remove('hidden');
if (show) {
el.className = 'draw-result ' + cls;
el.innerHTML = html;
}
el.classList.toggle('hidden', !show);
}
function showNormalState() {
document.getElementById('round-normal-row').classList.remove('hidden');
document.getElementById('round-stats-row').classList.remove('hidden');
document.getElementById('draw-state').classList.remove('active');
document.getElementById('draw-result').classList.add('hidden');
setRoundInfoVisible(true);
setDrawingBoxVisible(false);
setResultBoxVisible(false);
}
async function refreshRound() {
@@ -554,47 +590,64 @@ async function refreshRound() {
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
updateRoundTimer();
currentRoundIdSeen = data.round_id || null;
const isDrawing = data.round_id && DRAWING_STATUSES.includes(data.status);
document.getElementById('round-card').classList.toggle('drawing-glow', !!isDrawing);
const persisted = getPersistedResult();
if (isDrawing) {
if (!(data.round_id in drawStartedAt)) drawStartedAt[data.round_id] = Date.now();
const elapsedMs = Date.now() - drawStartedAt[data.round_id];
const minMs = data.draw_animation_seconds * 1000;
setRoundInfoVisible(false);
// The drawing-phase box (spinner + phase label) is generic status info —
// 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)) {
revealedRounds.add(data.round_id);
activeResultRoundId = data.round_id;
markResultSeen(data.round_id);
if (myUserId != null && data.winner_user_id === myUserId) {
const won = (data.winner_amount_sats / SATS_PER_PLM);
showResultState('🎉 Hai vinto! +' + won + ' PLM', 'win');
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount
} else {
showResultState('Non hai vinto questa volta.', 'lose');
// The cosmetic reveal delay is anchored to the server's closes_at, not to
// any client-side "when did I first see this" timestamp — a page reload
// (or repeated reloads) can never reset it, since it's derived purely
// from server-provided values that don't change for this round.
const elapsedMs = serverNow() - new Date(data.closes_at);
const minMs = data.draw_animation_seconds * 1000;
const alreadyKnown = persisted && persisted.round_id === data.round_id;
// 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
}
}
} else if (!revealedRounds.has(data.round_id)) {
showDrawingState(drawingLabelFor(data));
renderPersistedResult({ won, amount_sats: data.winner_amount_sats });
} else if (alreadyKnown) {
renderPersistedResult(persisted);
} else {
setResultBoxVisible(false);
}
} else if (data.round_id && data.round_id !== activeResultRoundId) {
// a genuinely new round is open — clear any previous result and go back to normal
activeResultRoundId = null;
// 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];
} else {
setDrawingBoxVisible(false);
if (data.round_id && (!persisted || data.round_id !== persisted.round_id)) {
// a genuinely new round is open — clear any previous result and go back to normal
clearPersistedResult();
showNormalState();
} else if (!data.round_id && !persisted) {
// nothing has ever been revealed and there's no active round — plain empty state
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);
}
for (const id of revealedRounds) {
if (id !== data.round_id) revealedRounds.delete(id);
}
showNormalState();
} else if (!data.round_id && activeResultRoundId == null) {
// nothing has ever been revealed and there's no active round — plain empty state
showNormalState();
}
// 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);
} catch (e) {
@@ -609,7 +662,7 @@ function scheduleNextRoundPoll(fast) {
roundPollTimeout = setTimeout(refreshRound, fast ? 3000 : 15000);
}
function showDashboard() {
async function showDashboard() {
sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login
stopChainOnlyPolling();
document.getElementById('landing-hero').classList.add('hidden');
@@ -619,7 +672,16 @@ function showDashboard() {
document.getElementById('dash-username').textContent = username;
document.getElementById('dash-address').textContent = 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();
checkLastRoundResult();
clearInterval(lastResultInterval);
@@ -676,9 +738,7 @@ function resetToLoggedOutUI() {
sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving
token = username = address = null;
myUserId = null;
activeResultRoundId = null;
revealedRounds.clear();
for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key];
currentRoundIdSeen = undefined;
clearInterval(roundTimerInterval);
clearTimeout(roundPollTimeout);
clearInterval(lastResultInterval);
@@ -734,7 +794,19 @@ async function copyAddress() {
}
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() {
const btn = document.getElementById('refresh-btn');
@@ -743,11 +815,13 @@ async function refreshMe() {
const data = await call('GET', '/users/me');
myUserId = data.id;
myBalanceSats = data.balance_sats;
document.getElementById('dash-balance').textContent = data.balance_sats / SATS_PER_PLM;
document.getElementById('navbar-balance').textContent = (data.balance_sats / SATS_PER_PLM) + ' PLM';
setBalanceDisplay('dash-balance', data.pending_balance_sats, data.has_pending);
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-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('wd-full-amount-value').textContent = data.balance_sats / SATS_PER_PLM;
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-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; }
+46 -1
View File
@@ -1,7 +1,9 @@
from embit.transaction import Transaction
from sqlalchemy import func, select
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:
@@ -14,3 +16,46 @@ async def recompute_balance(session: AsyncSession, user_id: int) -> int:
user = await session.get(User, user_id)
user.cached_balance_sats = balance or 0
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