diff --git a/BUGS.md b/BUGS.md index 7aeeda4..e90ff3a 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,42 +1,23 @@ # Known bugs A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high, -7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-36 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 13 are High/Medium/Low. +7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-37 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 12 are High/Medium/Low. The 139-test suite was green at the time of the audit, so none of these were caught by existing -coverage — every fix lands with a regression test (the twelve fixes so far brought the suite -from 139 to 200). +coverage — every fix lands with a regression test (the thirteen fixes so far brought the suite +from 139 to 201). The recurring pattern across the open findings is worth stating once: the code is rigorous about the failure modes that have actually been hit, and silent about the ones that have not. The payout phase is now fully recoverable; the "drawing" phase (waiting on a block) is now observable (B-36) but still has no equivalent resume-after-restart — see "Known gaps / TODO" -in [CLAUDE.md](CLAUDE.md). - -For limitations that are accepted by design rather than bugs (single-shared-token admin auth, -single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in -[CLAUDE.md](CLAUDE.md). +in [CLAUDE.md](CLAUDE.md), which is also where other by-design limitations (single-shared-token +admin auth, single-process assumptions, no user-facing history, etc.) are documented. --- ## Medium -### B-37 — Displayed balance and spendable balance diverge, and the error does not explain it - -After a bet the change is unconfirmed, so `cached_balance_sats` ≈ 0 while the UI shows -`pending_balance_sats` (the real figure). A withdrawal attempted right after validates against -**confirmed** UTXOs (`withdrawals/service.py:54-60`) and answers `insufficient_balance`. - -The user sees "1.000 PLM" on screen and is told they have no funds. The mechanism is a -documented design decision, but the error does not distinguish "you don't have the money" from -"your money is waiting to confirm" — two very different situations for whoever reads it. - -**Proposed fix.** A distinct error code (e.g. `balance_pending_confirmation`) raised when the -requested amount is covered by `pending_balance_sats` but not by the confirmed balance, -carrying the pending amount in `params`, plus its `error.*` entry in all 7 languages. The -withdrawal form should also cap/hint the max against the confirmed balance rather than the -displayed one. - ### B-38 — The 500-subscriber SSE cap is a zero-cost DoS of the realtime feature `GET /rounds/stream` requires no authentication and each connection takes a slot on a @@ -174,9 +155,10 @@ already does. - **B-34** — password change/reset didn't invalidate already-issued JWTs, so a stolen token survived a change meant to lock it out - **B-35** — API timestamps round-tripped as naive datetimes, so the frontend parsed them as local time instead of UTC - **B-36** — a stalled draw wait had no timeout, no log, and no audit trail, so a frozen round showed nothing in `/admin` +- **B-37** — a withdrawal covered by unconfirmed change answered "insufficient balance" instead of distinguishing it from actually having no funds See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the -B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36 fixes). Suite grew from 139 to 200 tests over the twelve. +B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36/B-37 fixes). Suite grew from 139 to 201 tests over the thirteen. A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical, diff --git a/CLAUDE.md b/CLAUDE.md index a18b7fc..16ae980 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. -**Read [BUGS.md](BUGS.md) before trusting any behaviour here.** Two audits: 2026-07-26 found 24 bugs (5 critical), all fixed; 2026-07-27 found 25 more (B-25 … B-49), of which **13 are still open** — no Critical or High remains, only Medium/Low: no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), a 500-subscriber SSE cap that doubles as a cheap DoS of the realtime feature (B-38), among others. BUGS.md is the live open list with a proposed fix per finding; "Known gaps" at the end of this file is for limitations accepted **by design** instead. Don't fix a BUGS.md item silently as a side effect of other work — each fix lands with its own regression test. +**Read [BUGS.md](BUGS.md) before trusting any behaviour here.** Two audits: 2026-07-26 found 24 bugs (5 critical), all fixed; 2026-07-27 found 25 more (B-25 … B-49), of which **12 are still open** — no Critical or High remains, only Medium/Low: no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), a 500-subscriber SSE cap that doubles as a cheap DoS of the realtime feature (B-38), among others. BUGS.md is the live open list with a proposed fix per finding; "Known gaps" at the end of this file is for limitations accepted **by design** instead. Don't fix a BUGS.md item silently as a side effect of other work — each fix lands with its own regression test. Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh .mmd` after editing either. @@ -174,7 +174,7 @@ At 120s blocks that's ~4–6 min worst case (last bet confirms right at the dead `place_bet`/`request_withdrawal` select whole UTXOs (`select_utxos`, largest-first) and mark each `spent_txid` at broadcast time, long before any confirmation. `cached_balance_sats` (`recompute_balance`) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change — often far more than the amount actually moving. -`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). The gap is user-visible and currently under-explained in errors (B-37). +`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). A withdrawal whose amount is covered by the pending-inclusive balance but not the confirmed one gets `balance_pending_confirmation` instead of a flat `insufficient_balance` (B-37), so the error doesn't contradict what the user is looking at. ## Real-time updates (SSE) diff --git a/app/static/i18n.js b/app/static/i18n.js index 96bfa9f..ae25689 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -121,6 +121,7 @@ const TRANSLATIONS = { 'error.round_closing': 'The current round is closing, please try again shortly.', 'error.already_betting': 'You already have an active bet in the current round.', 'error.insufficient_balance': 'Insufficient balance.', + 'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.', 'error.amount_below_network_fee': 'The amount is too small to cover the network fee.', 'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).', 'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.', @@ -259,6 +260,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.', 'error.already_betting': 'Hai già una bet attiva nel round corrente.', 'error.insufficient_balance': 'Saldo insufficiente.', + 'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.', 'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.", 'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).', 'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.", @@ -397,6 +399,7 @@ const TRANSLATIONS = { 'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.', 'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.', 'error.insufficient_balance': 'Saldo insuficiente.', + 'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.', 'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.', 'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).', 'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.', @@ -535,6 +538,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.', 'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.', 'error.insufficient_balance': 'Solde insuffisant.', + 'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.', 'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.', 'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).', 'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.', @@ -673,6 +677,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.', 'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.', 'error.insufficient_balance': 'Nicht genügend Guthaben.', + 'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.', 'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.', 'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).', 'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.', @@ -811,6 +816,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.', 'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.', 'error.insufficient_balance': 'Недостаточно средств.', + 'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.', 'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.', 'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).', 'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.', @@ -949,6 +955,7 @@ const TRANSLATIONS = { 'error.round_closing': '当前回合正在结束,请稍后重试。', 'error.already_betting': '你在当前回合已有一笔有效下注。', 'error.insufficient_balance': '余额不足。', + 'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。', 'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。', 'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。', 'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。', diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index ff520d6..84a7b6c 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -9,7 +9,7 @@ from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.wallet.address import is_valid_plm_address -from app.wallet.balance import recompute_balance +from app.wallet.balance import compute_pending_balance, recompute_balance from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import ( BuiltTransaction, @@ -56,7 +56,22 @@ async def request_withdrawal( select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None)) ) ).all() - if sum(u.amount_sats for u in unspent) < amount_sats: + confirmed_sats = sum(u.amount_sats for u in unspent) + if confirmed_sats < amount_sats: + # B-37: cached_balance_sats (== confirmed_sats here) can understate the real + # balance by a whole unconfirmed change output right after a bet/withdrawal — + # the UI shows pending_balance_sats instead (compute_pending_balance), which + # can cover an amount this check would otherwise reject as flatly + # "insufficient". Distinguish "you don't have the money" from "your money + # hasn't confirmed yet" so the error doesn't contradict what the user is + # looking at on screen. + pending_inclusive_sats, has_pending = await compute_pending_balance(session, user) + if has_pending and pending_inclusive_sats >= amount_sats: + raise WithdrawalError( + "balance_pending_confirmation", + "the requested amount is covered by your pending balance, which has not confirmed yet", + pending_sats=pending_inclusive_sats - confirmed_sats, + ) raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats) user_key = derive_user_key(user.derivation_index) diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py index e882cc2..975a1ba 100644 --- a/tests/unit/test_withdrawals.py +++ b/tests/unit/test_withdrawals.py @@ -2,6 +2,7 @@ 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, Withdrawal @@ -99,6 +100,35 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory): await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS) +async def test_withdrawal_distinguishes_pending_from_truly_insufficient_balance(session_factory): + """B-37: right after a bet, cached_balance_sats is ~0 because the whole funding + UTXO was spent as input and the change hasn't confirmed yet — but the UI shows + the pending-inclusive balance (compute_pending_balance), which does cover a + withdrawal of this size. The error must say "not confirmed yet", not flatly + "insufficient balance", or it contradicts what the user is looking at.""" + user_id = await _make_funded_user(session_factory, 4, 3_000_000_000) + bet_client = FakeElectrumClient() + + async with session_factory() as session: + user = await session.get(User, user_id) + await place_bet(session, bet_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 + + withdraw_client = FakeElectrumClient() + with pytest.raises(WithdrawalError) as exc_info: + # Above the withdrawal minimum (BET_AMOUNT_SATS) and covered by the + # unconfirmed change (~1_999_800_000 sats), but not by the (zero) + # confirmed balance. + await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, 1_500_000_000) + + assert exc_info.value.code == "balance_pending_confirmation" + assert exc_info.value.params["pending_sats"] > 0 + assert not withdraw_client.broadcasted + + @pytest.mark.parametrize( "address", [