diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 38a44b1..acddd07 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -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, ) diff --git a/app/api/routes/users.py b/app/api/routes/users.py index dd87ce0..b5da306 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -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(), ) diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 18de63e..d6a1d69 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -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)) diff --git a/app/static/index.html b/app/static/index.html index 70042c1..2d54dbf 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -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) { diff --git a/app/static/style.css b/app/static/style.css index dd50636..4d6aa86 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; } diff --git a/app/wallet/balance.py b/app/wallet/balance.py index 7b0fd4b..24bc502 100644 --- a/app/wallet/balance.py +++ b/app/wallet/balance.py @@ -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) diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py new file mode 100644 index 0000000..fff442c --- /dev/null +++ b/tests/unit/test_balance.py @@ -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 diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py new file mode 100644 index 0000000..1ba59bd --- /dev/null +++ b/tests/unit/test_rounds_route.py @@ -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