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>
102 lines
3.8 KiB
Python
102 lines
3.8 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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
|
|
|
|
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
|
|
|
|
|
class CurrentRoundResponse(BaseModel):
|
|
server_time: str
|
|
round_id: int | None = None
|
|
status: str | None = None
|
|
opened_at: str | None = None
|
|
closes_at: str | None = None
|
|
participant_count: int = 0
|
|
bet_amount_sats: int
|
|
jackpot_sats: int = 0
|
|
draw_animation_seconds: int
|
|
winner_user_id: int | None = None
|
|
winner_amount_sats: int | None = None
|
|
draw_block_height: int | None = None
|
|
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),
|
|
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
|
|
chain_tip_height = listener.tip_height or None
|
|
if round_ is None:
|
|
await session.commit()
|
|
return CurrentRoundResponse(
|
|
server_time=datetime.now(timezone.utc).isoformat(),
|
|
bet_amount_sats=config.bet_amount_sats,
|
|
draw_animation_seconds=config.draw_animation_seconds,
|
|
chain_tip_height=chain_tip_height,
|
|
lottery_paused=config.paused,
|
|
)
|
|
|
|
participant_count = await session.scalar(
|
|
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
|
) 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
|
|
# split rounds/scheduler.py applies at payout time), not the full pool —
|
|
# what's displayed should match what the winner actually receives.
|
|
pool_amount_sats = participant_count * config.bet_amount_sats
|
|
jackpot_sats = pool_amount_sats * 70 // 100
|
|
|
|
return CurrentRoundResponse(
|
|
server_time=datetime.now(timezone.utc).isoformat(),
|
|
round_id=round_.id,
|
|
status=round_.status,
|
|
opened_at=opened_at.isoformat(),
|
|
closes_at=closes_at.isoformat(),
|
|
participant_count=participant_count,
|
|
bet_amount_sats=config.bet_amount_sats,
|
|
jackpot_sats=jackpot_sats,
|
|
draw_animation_seconds=config.draw_animation_seconds,
|
|
winner_user_id=round_.winner_user_id,
|
|
winner_amount_sats=round_.winner_amount_sats,
|
|
draw_block_height=round_.draw_block_height,
|
|
draw_block_hash=round_.draw_block_hash,
|
|
chain_tip_height=chain_tip_height,
|
|
lottery_paused=config.paused,
|
|
user_played=user_played,
|
|
)
|