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:
@@ -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
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
+149
-75
@@ -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');
|
||||||
el.className = 'draw-result ' + cls;
|
if (show) {
|
||||||
el.innerHTML = html;
|
el.className = 'draw-result ' + cls;
|
||||||
el.classList.remove('hidden');
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
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(); // the win toast is useless if the balance card still shows the pre-payout amount
|
// (refreshMe() and refreshRound() run concurrently) — fall back to the
|
||||||
} else {
|
// persisted result rather than risk showing nothing or the wrong side.
|
||||||
showResultState('Non hai vinto questa volta.', 'lose');
|
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)) {
|
renderPersistedResult({ won, amount_sats: data.winner_amount_sats });
|
||||||
showDrawingState(drawingLabelFor(data));
|
} else if (alreadyKnown) {
|
||||||
|
renderPersistedResult(persisted);
|
||||||
|
} else {
|
||||||
|
setResultBoxVisible(false);
|
||||||
}
|
}
|
||||||
} else if (data.round_id && data.round_id !== activeResultRoundId) {
|
} else {
|
||||||
// a genuinely new round is open — clear any previous result and go back to normal
|
setDrawingBoxVisible(false);
|
||||||
activeResultRoundId = null;
|
if (data.round_id && (!persisted || data.round_id !== persisted.round_id)) {
|
||||||
// drop bookkeeping for old rounds so these maps don't grow for the life of the session
|
// a genuinely new round is open — clear any previous result and go back to normal
|
||||||
for (const key of Object.keys(drawStartedAt)) {
|
clearPersistedResult();
|
||||||
if (Number(key) !== data.round_id) delete drawStartedAt[key];
|
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);
|
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) {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user